s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (46 total matches found)
sonac.sbox-animator / Editor/Services/WeaponMaterialPipeline.cs
Editor library
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

internal static class WeaponMaterialPipeline
{
	private const string PreviewMaterialFormatVersion = "preview-material-v2";

	private static readonly HashSet<string> SupportedImageExtensions =
		new( StringComparer.OrdinalIgnoreCase )
		{
			".png", ".tga", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".dds", ".exr"
		};

	private static readonly string[] NearbyFolderNames =
		["textures", "texture", "materials", "material", "maps"];

	private sealed record TextureCandidate(
		string Path,
		string GroupName,
		WeaponTextureChannel Channel,
		int Priority );

	internal sealed record GeneratedTextureCopy(
		string RelativePath,
		string SourceAbsolute );

	public static List<SourceMaterialBinding> DiscoverAndPreparePreview(
		string absoluteSource,
		string cacheRoot,
		Asset modelAsset,
		Model? model,
		List<RigAuditIssue> issues,
		IEnumerable<string>? knownMaterialSlots = null )
	{
		var candidates = DiscoverTextureCandidates( absoluteSource );
		var slots = DiscoverMaterialSlots( modelAsset, model );
		slots.AddRange( knownMaterialSlots?
			.Where( slot => !string.IsNullOrWhiteSpace( slot ) )
			?? [] );
		var embeddedSlots = DiscoverEmbeddedMaterialNames(
			absoluteSource,
			candidates.Select( candidate => candidate.GroupName ) )
			.Select( name => $"{name}.vmat" )
			.ToArray();
		slots.AddRange( embeddedSlots );
		var embeddedSet = embeddedSlots.ToHashSet( StringComparer.OrdinalIgnoreCase );
		slots = slots
			.Where( slot => !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )
			.GroupBy( slot => NormalizeName( Path.GetFileNameWithoutExtension( slot ) ) )
			.Select( group => group.FirstOrDefault( embeddedSet.Contains ) ?? group.First() )
			.ToList();
		if ( slots.Count == 0 )
		{
			// Some interchange compilers omit unresolved material metadata. Texture set names
			// are the best deterministic fallback for the original slot labels.
			slots.AddRange( candidates
				.Select( candidate => candidate.GroupName )
				.Distinct( StringComparer.OrdinalIgnoreCase )
				.Select( name => $"{name}.vmat" ) );
		}

		var groups = candidates
			.GroupBy( candidate => NormalizeName( candidate.GroupName ) )
			.Where( group => !string.IsNullOrWhiteSpace( group.Key ) )
			.ToDictionary(
				group => group.Key,
				group => group.ToArray(),
				StringComparer.OrdinalIgnoreCase );
		var bindings = new List<SourceMaterialBinding>();
		var usedNames = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var slot in slots
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( name => name, StringComparer.OrdinalIgnoreCase ) )
		{
			var displayName = Path.GetFileNameWithoutExtension( slot );
			var outputName = UniqueOutputName(
				WeaponAnimationDocument.Slugify( displayName ),
				usedNames );
			var binding = new SourceMaterialBinding
			{
				SourceMaterialPath = StoredMaterialSlot( slot ),
				Name = displayName,
				OutputName = outputName
			};

			var matchingGroup = FindBestGroup( displayName, groups );
			if ( matchingGroup is not null )
			{
				foreach ( var channelGroup in matchingGroup
					.GroupBy( candidate => candidate.Channel )
					.OrderBy( group => group.Key ) )
				{
					var candidate = channelGroup
						.OrderByDescending( item => item.Priority )
						.ThenBy( item => item.Path, StringComparer.OrdinalIgnoreCase )
						.First();
					var hash = WeaponSourceImporter.HashFile( candidate.Path );
					var assetPath = EnsureTextureInsideAssets(
						candidate.Path,
						cacheRoot,
						hash );
					binding.Textures.Add( new SourceTextureMap
					{
						Channel = candidate.Channel,
						OriginalPath = candidate.Path,
						AssetPath = assetPath,
						Sha256 = hash
					} );
				}
			}

			if ( binding.FindTexture( WeaponTextureChannel.PackedOrm ) is not null
				&& !binding.HasUsableTextures )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.packed_orm",
					Message =
						$"Material '{displayName}' only has a packed ORM texture. "
						+ "Separate color, normal, roughness, or metalness maps are required "
						+ "for automatic assignment.",
					Severity = ValidationSeverity.Warning
				} );
			}
			else if ( !binding.HasUsableTextures )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.textures_missing",
					Message =
						$"No nearby texture set matched material '{displayName}'. "
						+ "That slot will use the default material.",
					Severity = ValidationSeverity.Warning
				} );
			}
			else if ( binding.FindTexture( WeaponTextureChannel.PackedOrm ) is not null
				&& binding.FindTexture( WeaponTextureChannel.Roughness ) is null
				&& binding.FindTexture( WeaponTextureChannel.Metalness ) is null )
			{
				issues.Add( new RigAuditIssue
				{
					Code = "material.packed_orm",
					Message =
						$"Material '{displayName}' only has a packed ORM texture. "
						+ "Separate roughness and metalness maps are required for automatic assignment.",
					Severity = ValidationSeverity.Warning
				} );
			}

			bindings.Add( binding );
		}

		PreparePreviewAssets( bindings, cacheRoot );
		return bindings;
	}

	public static IReadOnlyList<HostMaterialRemap> PreviewRemaps(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.Where( binding => !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )
			.Select( binding => new HostMaterialRemap(
				ResourceMaterialSlot( binding.SourceMaterialPath ),
				binding.HasUsableTextures
					&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )
						? binding.PreviewMaterialPath
						: "materials/default.vmat" ) )
			.ToArray();

	public static IReadOnlyList<HostMaterialRemap> OutputRemaps(
		WeaponAnimationDocument document,
		string relativeRoot )
	{
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		return document.Source.Materials
			.Where( binding => !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )
			.Select( binding => new HostMaterialRemap(
				ResourceMaterialSlot( binding.SourceMaterialPath ),
				binding.HasUsableTextures
					? $"{relativeRoot}/materials/{slug}_{binding.OutputName}.vmat"
					: "materials/default.vmat" ) )
			.ToArray();
	}

	public static bool RequiresPreviewRefresh( WeaponAnimationDocument document ) =>
		document.Source.NeedsModelDocWrapper
		&& (document.Source.Materials.Count == 0
			|| document.Source.CompiledModelPath.StartsWith(
				".weaponanim-cache/",
				StringComparison.OrdinalIgnoreCase )
			|| document.Source.Materials.Any( binding =>
				Path.GetExtension( binding.SourceMaterialPath ).Equals(
					".vmat",
					StringComparison.OrdinalIgnoreCase ) )
			|| document.Source.Materials.Any( binding =>
				binding.HasUsableTextures
				&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )
				&& (binding.PreviewMaterialPath.StartsWith(
						".weaponanim-cache/",
						StringComparison.OrdinalIgnoreCase )
					|| binding.PreviewMaterialPath.Contains(
						"/texture-definitions/",
						StringComparison.OrdinalIgnoreCase )) ));

	public static Dictionary<string, string> BuildOutputTextFiles(
		WeaponAnimationDocument document,
		string relativeRoot )
	{
		var files = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		foreach ( var binding in document.Source.Materials
			.Where( binding => binding.HasUsableTextures ) )
		{
			var texturePaths = new Dictionary<WeaponTextureChannel, string>();
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				var imageName = OutputTextureImageName( slug, binding, texture );
				var imageRelative = $"textures/{imageName}";
				var texturePath = $"{relativeRoot}/{imageRelative}";
				texturePaths[texture.Channel] = texturePath;
			}

			files[$"materials/{slug}_{binding.OutputName}.vmat"] =
				WriteVmat( texturePaths );
		}

		return files;
	}

	public static IReadOnlyList<GeneratedTextureCopy> BuildOutputTextureCopies(
		WeaponAnimationDocument document )
	{
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		var copies = new List<GeneratedTextureCopy>();
		foreach ( var binding in document.Source.Materials
			.Where( binding => binding.HasUsableTextures ) )
		{
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				var source = ResolveTextureAbsolute( texture );
				copies.Add( new GeneratedTextureCopy(
					$"textures/{OutputTextureImageName( slug, binding, texture )}",
					source ) );
			}
		}

		return copies;
	}

	internal static IReadOnlyList<SourceMaterialBinding> DiscoverForTests(
		IEnumerable<string> materialSlots,
		IEnumerable<string> texturePaths )
	{
		var candidates = texturePaths
			.Select( TryCreateCandidate )
			.Where( candidate => candidate is not null )
			.Cast<TextureCandidate>()
			.ToArray();
		var groups = candidates
			.GroupBy( candidate => NormalizeName( candidate.GroupName ) )
			.ToDictionary(
				group => group.Key,
				group => group.ToArray(),
				StringComparer.OrdinalIgnoreCase );
		var usedNames = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		return materialSlots
			.Where( slot => !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )
			.Select( slot =>
		{
			var name = Path.GetFileNameWithoutExtension( slot );
			var binding = new SourceMaterialBinding
			{
				SourceMaterialPath = StoredMaterialSlot( slot ),
				Name = name,
				OutputName = UniqueOutputName(
					WeaponAnimationDocument.Slugify( name ),
					usedNames )
			};
			var group = FindBestGroup( name, groups );
			if ( group is not null )
			{
				binding.Textures = group
					.GroupBy( candidate => candidate.Channel )
					.Select( channel => channel.OrderByDescending( item => item.Priority ).First() )
					.Select( item => new SourceTextureMap
					{
						Channel = item.Channel,
						OriginalPath = item.Path,
						AssetPath = item.Path
					} )
					.ToList();
			}
			return binding;
		} ).ToArray();
	}

	internal static IReadOnlyList<string> MatchEmbeddedMaterialNamesForTests(
		IEnumerable<string> textureGroups,
		IEnumerable<string> embeddedStrings ) =>
		MatchEmbeddedMaterialNames( textureGroups, embeddedStrings );

	internal static string PreviewRevision(
		IEnumerable<SourceMaterialBinding> bindings )
	{
		var fingerprint = PreviewMaterialFormatVersion
			+ "\n"
			+ string.Join(
			"\n",
			bindings
				.OrderBy(
					binding => binding.SourceMaterialPath,
					StringComparer.OrdinalIgnoreCase )
				.Select( binding =>
					$"{NormalizeMaterialPath( binding.SourceMaterialPath )}|{binding.OutputName}|"
					+ string.Join(
						",",
						binding.Textures
							.OrderBy( texture => texture.Channel )
							.ThenBy(
								texture => texture.AssetPath,
								StringComparer.OrdinalIgnoreCase )
							.Select( texture =>
								$"{texture.Channel}:{texture.Sha256}:{texture.AssetPath}" ) ) ) );
		return WeaponSourceImporter.HashText( fingerprint )[..16];
	}

	internal static string PreviewRevisionRoot(
		string cacheRoot,
		IEnumerable<SourceMaterialBinding> bindings ) =>
		Path.Combine(
			LegalPreviewCacheRoot( cacheRoot ),
			PreviewRevision( bindings ) );

	internal static IReadOnlyList<string> PreviewMaterialAbsolutePaths(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.Where( binding => binding.HasUsableTextures
				&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath ) )
			.Select( binding => Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				binding.PreviewMaterialPath.Replace(
					'/',
					Path.DirectorySeparatorChar ) ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();

	internal static IReadOnlyList<string> PreviewTextureAbsolutePaths(
		IEnumerable<SourceMaterialBinding> bindings ) =>
		bindings
			.SelectMany( binding => binding.Textures )
			.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm
				&& !string.IsNullOrWhiteSpace( texture.AssetPath ) )
			.Select( texture => Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				texture.AssetPath.Replace(
					'/',
					Path.DirectorySeparatorChar ) ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();

	internal static string LegalPreviewRelativeRootForTests( string cacheRoot )
	{
		var documentFolder = Path.GetFileName(
			cacheRoot.TrimEnd(
				Path.DirectorySeparatorChar,
				Path.AltDirectorySeparatorChar ) );
		return $"weaponanim_preview_cache/{documentFolder}";
	}

	private static void PreparePreviewAssets(
		IEnumerable<SourceMaterialBinding> bindings,
		string cacheRoot )
	{
		var materialBindings = bindings.ToArray();
		var legalCacheRoot = PreviewRevisionRoot( cacheRoot, materialBindings );
		var materialRoot = Path.Combine( legalCacheRoot, "materials" );
		Directory.CreateDirectory( materialRoot );
		AtomicFile.WriteAllText(
			Path.Combine( legalCacheRoot, ".weaponanim-preview-version" ),
			PreviewMaterialFormatVersion );

		// Register source images before the directory watcher sees VMAT consumers. Otherwise
		// the dependency tracker can permanently mark a copied channel as "stopped existing".
		foreach ( var textureAbsolute in PreviewTextureAbsolutePaths( materialBindings ) )
			AssetSystem.RegisterFile( textureAbsolute );

		foreach ( var binding in materialBindings )
		{
			if ( !binding.HasUsableTextures )
			{
				binding.PreviewMaterialPath = "materials/default.vmat";
				continue;
			}

			var texturePaths = new Dictionary<WeaponTextureChannel, string>();
			foreach ( var texture in binding.Textures
				.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )
			{
				texturePaths[texture.Channel] = texture.AssetPath;
			}

			var vmatAbsolute = Path.Combine( materialRoot, $"{binding.OutputName}.vmat" );
			AtomicFile.WriteAllText( vmatAbsolute, WriteVmat( texturePaths ) );
			binding.PreviewMaterialPath = WeaponSourceImporter.RelativeAssetPath( vmatAbsolute );
		}
	}

	private static List<string> DiscoverMaterialSlots( Asset modelAsset, Model? model )
	{
		var slots = new List<string>();
		try
		{
			slots.AddRange( modelAsset.GetUnrecognizedReferencePaths()
				.Where( IsSourceMaterialPath ) );
		}
		catch ( Exception ex )
		{
			Log.Warning(
				$"[Weapon Animator] could not inspect unresolved source material slots: {ex.Message}" );
		}

		if ( model is not null && !model.IsError )
		{
			try
			{
				slots.AddRange( model.Materials
					.Select( material => material.Name )
					.Where( IsSourceMaterialPath ) );
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] could not inspect compiled model material slots: {ex.Message}" );
			}
		}
		return slots
			.Select( NormalizeMaterialPath )
			.Where( path => !path.Contains(
				".weaponanim-cache/",
				StringComparison.OrdinalIgnoreCase ) )
			.Where( path => !IsIgnoredMaterialPath( path ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToList();
	}

	private static bool IsSourceMaterialPath( string? path ) =>
		!string.IsNullOrWhiteSpace( path )
		&& Path.GetExtension( path ).Equals( ".vmat", StringComparison.OrdinalIgnoreCase );

	private static List<TextureCandidate> DiscoverTextureCandidates( string sourcePath )
	{
		var directories = NearbyDirectories( sourcePath );
		var files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		foreach ( var directory in directories )
		{
			try
			{
				foreach ( var file in Directory.EnumerateFiles( directory )
					.Where( file => SupportedImageExtensions.Contains( Path.GetExtension( file ) ) )
					.Take( 512 ) )
				{
					files.Add( Path.GetFullPath( file ) );
				}
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] could not inspect nearby texture folder '{directory}': {ex.Message}" );
			}
		}

		return files
			.Select( TryCreateCandidate )
			.Where( candidate => candidate is not null )
			.Cast<TextureCandidate>()
			.ToList();
	}

	private static IReadOnlyList<string> DiscoverEmbeddedMaterialNames(
		string sourcePath,
		IEnumerable<string> textureGroups )
	{
		if ( !Path.GetExtension( sourcePath ).Equals(
			".fbx",
			StringComparison.OrdinalIgnoreCase ) )
			return [];

		try
		{
			return MatchEmbeddedMaterialNames(
				textureGroups,
				ReadPrintableStrings( sourcePath ) );
		}
		catch ( Exception ex )
		{
			Log.Warning(
				$"[Weapon Animator] could not inspect embedded FBX material labels: {ex.Message}" );
			return [];
		}
	}

	private static IReadOnlyList<string> MatchEmbeddedMaterialNames(
		IEnumerable<string> textureGroups,
		IEnumerable<string> embeddedStrings )
	{
		var strings = embeddedStrings
			.Where( value => value.Length is >= 2 and <= 128
				&& !value.Contains( '/' )
				&& !value.Contains( '\\' )
				&& !value.Contains( '.' ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();
		var names = new List<string>();
		foreach ( var group in textureGroups
			.Distinct( StringComparer.OrdinalIgnoreCase ) )
		{
			var normalizedGroup = NormalizeName( group );
			var match = strings
				.Where( value => NormalizeName( value ).Equals(
					normalizedGroup,
					StringComparison.OrdinalIgnoreCase ) )
				.OrderBy( value => value.Length )
				.ThenBy( value => value, StringComparer.OrdinalIgnoreCase )
				.FirstOrDefault();
			if ( !string.IsNullOrWhiteSpace( match ) )
				names.Add( match );
		}
		return names.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();
	}

	private static IEnumerable<string> ReadPrintableStrings( string path )
	{
		using var stream = File.OpenRead( path );
		var builder = new StringBuilder();
		var buffer = new byte[64 * 1024];
		int count;
		while ( (count = stream.Read( buffer, 0, buffer.Length )) > 0 )
		{
			for ( var index = 0; index < count; index++ )
			{
				var value = buffer[index];
				if ( value is >= 32 and <= 126 )
				{
					if ( builder.Length < 512 )
						builder.Append( (char)value );
					continue;
				}

				if ( builder.Length >= 2 )
					yield return builder.ToString();
				builder.Clear();
			}
		}
		if ( builder.Length >= 2 )
			yield return builder.ToString();
	}

	private static IEnumerable<string> NearbyDirectories( string sourcePath )
	{
		var sourceDirectory = Path.GetDirectoryName( sourcePath );
		if ( string.IsNullOrWhiteSpace( sourceDirectory ) )
			yield break;

		var found = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		if ( found.Add( sourceDirectory ) )
			yield return sourceDirectory;

		foreach ( var root in new[]
			{
				sourceDirectory,
				Directory.GetParent( sourceDirectory )?.FullName
			}.Where( root => !string.IsNullOrWhiteSpace( root ) ) )
		{
			foreach ( var folder in NearbyFolderNames )
			{
				var candidate = Path.Combine( root!, folder );
				if ( Directory.Exists( candidate ) && found.Add( candidate ) )
					yield return candidate;
			}

			IEnumerable<string> children;
			try
			{
				children = Directory.EnumerateDirectories( root! ).ToArray();
			}
			catch
			{
				continue;
			}

			foreach ( var child in children.Where( child =>
				NearbyFolderNames.Any( folder =>
					Path.GetFileName( child ).Contains(
						folder,
						StringComparison.OrdinalIgnoreCase ) ) ) )
			{
				if ( found.Add( child ) )
					yield return child;
			}
		}
	}

	private static TextureCandidate? TryCreateCandidate( string path )
	{
		var stem = Path.GetFileNameWithoutExtension( path );
		var normalized = NormalizeSeparators( stem );
		var patterns = new (string Token, WeaponTextureChannel Channel, int Priority)[]
		{
			("occlusion_roughness_metallic", WeaponTextureChannel.PackedOrm, 100),
			("occlusionroughnessmetallic", WeaponTextureChannel.PackedOrm, 100),
			("normal_opengl", WeaponTextureChannel.Normal, 145),
			("normal_gl", WeaponTextureChannel.Normal, 145),
			("nrm_gl", WeaponTextureChannel.Normal, 140),
			("normal_directx", WeaponTextureChannel.Normal, 80),
			("normal_dx", WeaponTextureChannel.Normal, 80),
			("nrm_dx", WeaponTextureChannel.Normal, 75),
			("base_color", WeaponTextureChannel.BaseColor, 120),
			("basecolor", WeaponTextureChannel.BaseColor, 120),
			("albedo", WeaponTextureChannel.BaseColor, 115),
			("diffuse", WeaponTextureChannel.BaseColor, 110),
			("color", WeaponTextureChannel.BaseColor, 100),
			("ambient_occlusion", WeaponTextureChannel.AmbientOcclusion, 120),
			("ambientocclusion", WeaponTextureChannel.AmbientOcclusion, 120),
			("occlusion", WeaponTextureChannel.AmbientOcclusion, 100),
			("roughness", WeaponTextureChannel.Roughness, 120),
			("rough", WeaponTextureChannel.Roughness, 110),
			("metalness", WeaponTextureChannel.Metalness, 120),
			("metallic", WeaponTextureChannel.Metalness, 120),
			("metal", WeaponTextureChannel.Metalness, 100),
			("normal", WeaponTextureChannel.Normal, 110),
			("nrm", WeaponTextureChannel.Normal, 105),
			("diff", WeaponTextureChannel.BaseColor, 90),
			("ao", WeaponTextureChannel.AmbientOcclusion, 90),
			("orm", WeaponTextureChannel.PackedOrm, 90),
			("rma", WeaponTextureChannel.PackedOrm, 85),
			("mra", WeaponTextureChannel.PackedOrm, 85)
		};

		foreach ( var pattern in patterns )
		{
			var marker = $"_{pattern.Token}";
			var index = normalized.LastIndexOf( marker, StringComparison.Ordinal );
			if ( index < 0 && normalized.Equals( pattern.Token, StringComparison.Ordinal ) )
				index = 0;
			if ( index < 0 )
				continue;

			var group = normalized[..index].Trim( '_' );
			if ( string.IsNullOrWhiteSpace( group ) )
				continue;
			return new TextureCandidate(
				path,
				group,
				pattern.Channel,
				pattern.Priority );
		}

		return null;
	}

	private static TextureCandidate[]? FindBestGroup(
		string materialName,
		IReadOnlyDictionary<string, TextureCandidate[]> groups )
	{
		var normalizedMaterial = NormalizeName( materialName );
		var best = groups
			.Select( pair => new
			{
				pair.Value,
				Score = MatchScore( normalizedMaterial, pair.Key )
			} )
			.OrderByDescending( item => item.Score )
			.FirstOrDefault();
		return best is not null && best.Score > 0 ? best.Value : null;
	}

	private static int MatchScore( string material, string group )
	{
		if ( material.Equals( group, StringComparison.OrdinalIgnoreCase ) )
			return 10000;
		if ( material.Contains( group, StringComparison.OrdinalIgnoreCase )
			|| group.Contains( material, StringComparison.OrdinalIgnoreCase ) )
			return 1000 + Math.Min( material.Length, group.Length );
		return 0;
	}

	private static string EnsureTextureInsideAssets(
		string source,
		string cacheRoot,
		string hash )
	{
		// Preview revisions reference immutable, legal resource names rather than arbitrary
		// user filenames or an image which can change underneath the active model.
		var sourceRoot = Path.Combine(
			LegalPreviewCacheRoot( cacheRoot ),
			"source-textures" );
		Directory.CreateDirectory( sourceRoot );
		var fileName =
			$"{WeaponAnimationDocument.Slugify( Path.GetFileNameWithoutExtension( source ) )}"
			+ $"_{hash[..12]}{Path.GetExtension( source ).ToLowerInvariant()}";
		var destination = Path.Combine( sourceRoot, fileName );
		if ( !File.Exists( destination )
			|| !WeaponSourceImporter.HashFile( destination ).Equals(
				hash,
				StringComparison.OrdinalIgnoreCase ) )
		{
			File.Copy( source, destination, true );
		}
		return WeaponSourceImporter.RelativeAssetPath( destination );
	}

	private static string ResolveTextureAbsolute( SourceTextureMap texture )
	{
		if ( !string.IsNullOrWhiteSpace( texture.AssetPath ) )
		{
			var candidate = Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				texture.AssetPath.Replace( '/', Path.DirectorySeparatorChar ) );
			if ( File.Exists( candidate ) )
				return candidate;
		}

		if ( !string.IsNullOrWhiteSpace( texture.OriginalPath )
			&& File.Exists( texture.OriginalPath ) )
			return Path.GetFullPath( texture.OriginalPath );

		throw new FileNotFoundException(
			$"Texture source for {texture.Channel} is missing.",
			texture.AssetPath );
	}

	private static string OutputTextureImageName(
		string slug,
		SourceMaterialBinding binding,
		SourceTextureMap texture )
	{
		var extension = Path.GetExtension(
			string.IsNullOrWhiteSpace( texture.AssetPath )
				? texture.OriginalPath
				: texture.AssetPath );
		if ( !SupportedImageExtensions.Contains( extension ) )
			extension = ".png";
		return $"{slug}_{binding.OutputName}_{ChannelSuffix( texture.Channel )}"
			+ extension.ToLowerInvariant();
	}

	private static string WriteVmat(
		IReadOnlyDictionary<WeaponTextureChannel, string> textures )
	{
		string TexturePath( WeaponTextureChannel channel, string fallback ) =>
			textures.TryGetValue( channel, out var path )
				? path.Replace( '\\', '/' )
				: fallback;
		var metalness = textures.TryGetValue(
			WeaponTextureChannel.Metalness,
			out var metalnessPath )
				? $$"""

						F_METALNESS_TEXTURE 1
						TextureMetalness "{{metalnessPath.Replace( '\\', '/' )}}"
					"""
				: "";

		return $$"""
			// SboxWeaponAnimator generated material.
			Layer0
			{
				shader "shaders/complex.shader"

				F_SPECULAR 1
				TextureAmbientOcclusion "{{TexturePath( WeaponTextureChannel.AmbientOcclusion, "materials/default/default_ao.tga" )}}"
				TextureColor "{{TexturePath( WeaponTextureChannel.BaseColor, "materials/default/default_color.tga" )}}"
				TextureNormal "{{TexturePath( WeaponTextureChannel.Normal, "materials/default/default_normal.tga" )}}"
				TextureRoughness "{{TexturePath( WeaponTextureChannel.Roughness, "materials/default/default_rough.tga" )}}"{{metalness}}
				g_flModelTintAmount "1.000"
				g_vColorTint "[1.000000 1.000000 1.000000 0.000000]"
				g_flRoughnessScaleFactor "1.000"
				g_bFogEnabled "1"
			}
			""";
	}

	private static string NormalizeMaterialPath( string value )
	{
		var normalized = value.Replace( '\\', '/' ).Trim();
		if ( !Path.HasExtension( normalized ) )
			normalized += ".vmat";
		return normalized;
	}

	internal static string StoredMaterialSlot( string value )
	{
		var normalized = NormalizeMaterialPath( value );
		return normalized.EndsWith( ".vmat", StringComparison.OrdinalIgnoreCase )
			? normalized[..^5]
			: normalized;
	}

	private static string ResourceMaterialSlot( string value ) =>
		NormalizeMaterialPath( value );

	private static bool IsIgnoredMaterialPath( string path ) =>
		path.Equals( "materials/default.vmat", StringComparison.OrdinalIgnoreCase )
		|| path.Equals( "materials/error.vmat", StringComparison.OrdinalIgnoreCase )
		|| path.Equals(
			"materials/tools/toolsinvisible.vmat",
			StringComparison.OrdinalIgnoreCase );

	private static string LegalPreviewCacheRoot( string cacheRoot )
	{
		return Path.Combine(
			WeaponSourceImporter.GetContentRoot(),
			LegalPreviewRelativeRootForTests( cacheRoot ).Replace(
				'/',
				Path.DirectorySeparatorChar ) );
	}

	private static string NormalizeSeparators( string value )
	{
		var builder = new StringBuilder( value.Length );
		var previousSeparator = false;
		foreach ( var character in value.ToLowerInvariant() )
		{
			var separator = !char.IsLetterOrDigit( character );
			if ( separator )
			{
				if ( !previousSeparator )
					builder.Append( '_' );
			}
			else
			{
				builder.Append( character );
			}
			previousSeparator = separator;
		}
		return builder.ToString().Trim( '_' );
	}

	private static string NormalizeName( string value ) =>
		new( value
			.Where( char.IsLetterOrDigit )
			.Select( char.ToLowerInvariant )
			.ToArray() );

	private static string UniqueOutputName(
		string baseName,
		HashSet<string> usedNames )
	{
		if ( string.IsNullOrWhiteSpace( baseName ) )
			baseName = "material";
		var candidate = baseName;
		var suffix = 2;
		while ( !usedNames.Add( candidate ) )
			candidate = $"{baseName}_{suffix++}";
		return candidate;
	}

	private static string ChannelSuffix( WeaponTextureChannel channel ) => channel switch
	{
		WeaponTextureChannel.BaseColor => "color",
		WeaponTextureChannel.Normal => "normal",
		WeaponTextureChannel.Roughness => "roughness",
		WeaponTextureChannel.Metalness => "metalness",
		WeaponTextureChannel.AmbientOcclusion => "ao",
		_ => "orm"
	};
}
sonac.sbox-animator / Editor/Tests/WeaponAnimatorSelfTests.cs
Editor library
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class WeaponAnimatorSelfTestReport
{
	public int Passed { get; internal set; }
	public List<string> Failures { get; } = [];
	public bool Success => Failures.Count == 0;

	public override string ToString() => Success
		? $"Weapon Animator self-tests passed ({Passed} checks)."
		: $"Weapon Animator self-tests failed ({Failures.Count} failures, {Passed} checks passed):\n" +
			string.Join( "\n", Failures.Select( x => $"  • {x}" ) );
}

public static class WeaponAnimatorSelfTests
{
	public static WeaponAnimatorSelfTestReport RunAll()
	{
		var report = new WeaponAnimatorSelfTestReport();
		Run( report, "document roles", TestDocumentRoles );
		Run( report, "custom clip management and document title", TestCustomClipManagement );
		Run( report, "scale and units", TestScaleAndUnits );
		Run( report, "anchor lifecycle", TestAnchorLifecycle );
		Run( report, "default grip binding", TestDefaultGripBinding );
		Run( report, "weapon subtree filtering", TestWeaponSubtreeFiltering );
		Run( report, "rig browser grouping", TestRigBrowserGrouping );
		Run( report, "bind pose parity", TestBindPoseParity );
		Run( report, "neutral arm binding", TestNeutralArmBinding );
		Run( report, "generated Idle recovery", TestGeneratedIdleRecovery );
		Run( report, "selection field isolation", TestSelectionFieldIsolation );
		Run( report, "working pose and auto-key", TestWorkingPose );
		Run( report, "stepped part visibility", TestPartVisibility );
		Run( report, "schema migration", TestSchemaMigration );
		Run( report, "content-sized buttons", TestContentSizedButtons );
		Run( report, "alignment", TestAlignment );
		Run( report, "track interpolation", TestInterpolation );
		Run( report, "curve editor v2", TestCurveEditorV2 );
		Run( report, "frame snapping", TestFrameSnapping );
		Run( report, "timeline navigation", TestTimelineNavigation );
		Run( report, "timeline selection and movement", TestTimelineSelectionAndMovement );
		Run( report, "timeline key reversal", TestTimelineKeyReversal );
		Run( report, "timeline playback", TestTimelinePlayback );
		Run( report, "two-bone IK", TestTwoBoneIk );
		Run( report, "IK descendant propagation", TestIkDescendantPropagation );
		Run( report, "timed constraints before IK", TestConstraintDrivenIk );
		Run( report, "constraint maintained offset", TestConstraintMaintainedOffset );
		Run( report, "history and key clipboard", TestControllerHistoryAndClipboard );
		Run( report, "host skeleton cache invalidation", TestHostSkeletonCache );
		Run( report, "calibration and generation validation", TestValidation );
		Run( report, "generation output paths", TestGenerationOutputPaths );
		Run( report, "material discovery and output", TestMaterialPipeline );
		Run( report, "generated file removal", TestGeneratedFileRemoval );
		Run( report, "calibration rebase", TestRebase );
		Run( report, "DMX output", TestDmxOutput );
		Run( report, "filtered source wrapper", TestFilteredSourceWrapper );
		Run( report, "generation source adapters", TestGenerationSourceAdapters );
		Run( report, "deterministic generated text", TestDeterministicOutput );
		Run( report, "AnimGraph tags and fallbacks", TestAnimGraphTagsAndFallbacks );
		return report;
	}

	[Menu( "Editor", "Tools/Weapon Animator/Run Self Tests", "science" )]
	public static void RunFromEditor()
	{
		var report = RunAll();
		if ( report.Success )
			Log.Info( $"[Weapon Animator] {report}" );
		else
			Log.Error( $"[Weapon Animator] {report}" );
	}

	private static void TestDocumentRoles( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Test Rifle" );
		Equal(
			report,
			WeaponAnimationDocument.StandardClips().Count,
			document.Clips.Count,
			"Default document must contain every standard slot." );
		Equal(
			report,
			WeaponClipRole.Idle,
			document.GetSelectedClip()!.Role,
			"Idle must be selected in a new document." );
		Check(
			report,
			!document.Workspace.ShowGuides,
			"Viewport guides must be opt-in for new projects." );
		Check(
			report,
			!document.Workspace.FreeLookCamera,
			"New projects must open with the familiar orbit camera." );
		Check(
			report,
			!document.Workspace.FullBrightViewport,
			"New projects must open with lit viewport rendering." );
		Check(
			report,
			document.Workspace.RimLightEnabled,
			"The cyan viewport edge light must remain available by default." );
		Near(
			report,
			4.0f,
			document.Workspace.RimLightIntensity,
			0.0001f,
			"The edge light default must be restrained rather than the old over-bright value." );
		Near(
			report,
			1.0f,
			document.Workspace.CameraMoveSpeed,
			0.0001f,
			"The free-look camera must start at normal movement speed." );
		Check(
			report,
			document.Workspace.SnapRotation,
			"Rotation snapping must be enabled in new projects." );
		Near(
			report,
			15.0f,
			document.Workspace.RotationSnapDegrees,
			0.0001f,
			"Rotation snapping must start at the familiar 15-degree step." );
		Near(
			report,
			30.0f,
			WeaponAnimatorViewport.AdjustRotationSnapAngle( 15.0f, 1 ),
			0.0001f,
			"The snap-angle stepper must advance through the standard angle presets." );
		Near(
			report,
			5.0f,
			WeaponAnimatorViewport.AdjustRotationSnapAngle( 15.0f, -1 ),
			0.0001f,
			"The snap-angle stepper must move backward through the standard angle presets." );
		Near(
			report,
			0.25f,
			WeaponAnimatorViewport.AdjustRotationSnapAngle( 0.25f, -1 ),
			0.0001f,
			"The snap-angle stepper must retain its lower bound." );
		Near(
			report,
			180.0f,
			WeaponAnimatorViewport.AdjustRotationSnapAngle( 180.0f, 1 ),
			0.0001f,
			"The snap-angle stepper must retain its upper bound." );
		Near(
			report,
			1.25f,
			WeaponAnimatorViewport.AdjustCameraSpeed( 1.0f, 1 ),
			0.0001f,
			"Free-look wheel-up must increase low movement speeds in fine steps." );
		Near(
			report,
			0.75f,
			WeaponAnimatorViewport.AdjustCameraSpeed( 1.0f, -1 ),
			0.0001f,
			"Free-look wheel-down must decrease low movement speeds in fine steps." );
		Near(
			report,
			100.0f,
			WeaponAnimatorViewport.AdjustCameraSpeed( 100.0f, 1 ),
			0.0001f,
			"Free-look movement speed must remain within its upper bound." );
		Near(
			report,
			0.25f,
			WeaponAnimatorViewport.AdjustCameraSpeed( 0.25f, -1 ),
			0.0001f,
			"Free-look movement speed must remain within its lower bound." );
		Near(
			report,
			0.10f,
			document.Workspace.GridOpacity,
			0.0001f,
			"The default viewport grid must be substantially quieter than the editor grid." );
		Near(
			report,
			0.65f,
			document.Workspace.GridLineThickness,
			0.0001f,
			"The default viewport grid must use fine lines." );
		var gridStyle = GridVisualStyle.Resolve(
			document.Workspace.GridOpacity,
			document.Workspace.GridLineThickness );
		Near(
			report,
			document.Workspace.GridOpacity,
			gridStyle.AxisOpacity,
			0.0001f,
			"The opacity preference must affect the colored origin axes." );
		Check(
			report,
			gridStyle.AxisWidth < 1
				&& gridStyle.AxisWidth > gridStyle.MajorWidth
				&& gridStyle.MajorWidth > gridStyle.MinorWidth,
			"The line-weight preference must allow thin axes while preserving grid hierarchy." );
		var faintStyle = GridVisualStyle.Resolve( 0.02f, 0.1f );
		Check(
			report,
			faintStyle.AxisOpacity < gridStyle.AxisOpacity
				&& faintStyle.AxisWidth < gridStyle.AxisWidth,
			"Lower opacity and weight must visibly affect both primary and secondary grid lines." );
		var rimStyle = ViewportRimLightStyle.Resolve(
			document.Workspace.RimLightEnabled,
			document.Workspace.RimLightIntensity,
			false );
		Check(
			report,
			rimStyle.Enabled,
			"The edge-light preference must enable the cyan point light in lit mode." );
		Near(
			report,
			4.0f,
			rimStyle.Intensity,
			0.0001f,
			"The viewport must apply the persisted edge-light brightness." );
		Check(
			report,
			!ViewportRimLightStyle.Resolve( true, 4, true ).Enabled
				&& !ViewportRimLightStyle.Resolve( false, 4, false ).Enabled,
			"Full Bright and the explicit toggle must both disable the edge light." );
		Near(
			report,
			12,
			ViewportRimLightStyle.Resolve( true, 99, false ).Intensity,
			0.0001f,
			"Edge-light brightness must remain inside its supported range." );
		var fullBrightArms = ArmPreviewVisualStyle.Resolve(
			WeaponAnimatorStage.Animate,
			true );
		Check(
			report,
			fullBrightArms.UseFlatMaterial
				&& MathF.Max(
					fullBrightArms.Tint.r,
					MathF.Max( fullBrightArms.Tint.g, fullBrightArms.Tint.b ) ) > 0.1f,
			"Full Bright must use a visible neutral arms material instead of rendering skin black." );
		Check(
			report,
			!ArmPreviewVisualStyle.Resolve( WeaponAnimatorStage.Animate, false ).UseFlatMaterial,
			"Lit animation preview must preserve the production arms materials." );
		// The four *_ikrule names are the real helper bones on the Facepunch arms; ik_hand_* are
		// added by HostSkeletonBuilder. None are read by anything, and all trail long lines.
		foreach ( var ikName in new[]
		{
			"hand_R_to_L_ikrule",
			"hand_L_to_R_ikrule",
			"hand_R_to_weapon_ikrule",
			"hand_L_to_weapon_ikrule",
			"ik_hand_R",
			"ik_hand_L",
			"weapon_IK_hand_R",
			"weapon_IK_hand_L"
		} )
		{
			Check(
				report,
				SkeletonBoneStyle.Classify( new HostBone { Name = ikName } ) == SkeletonBoneKind.Ik,
				$"{ikName} must be treated as an IK helper bone." );
		}
		// Weapon rigs ship their own IK targets, so the IK test deliberately wins over IsWeaponBone.
		Check(
			report,
			SkeletonBoneStyle.Classify(
				new HostBone { Name = "weapon_IK_hand_R", IsWeaponBone = true } )
					== SkeletonBoneKind.Ik,
			"An IK target from the weapon rig must be treated as an IK helper, not a weapon bone." );
		// The trap in letting IK win: "ik" must match as a token, never as a substring.
		foreach ( var keptName in new[]
		{
			"weapon_root",
			"spike_guard",
			"strike_plate",
			"trigger",
			"slide_kick",
			"ikon"
		} )
		{
			Check(
				report,
				SkeletonBoneStyle.Classify(
					new HostBone { Name = keptName, IsWeaponBone = true } )
						== SkeletonBoneKind.Weapon,
				$"{keptName} must stay a visible weapon bone - 'ik' matches tokens, not substrings." );
		}
		Check(
			report,
			SkeletonBoneStyle.Classify( new HostBone { Name = "arm_lower_R_twist1" } )
					== SkeletonBoneKind.Twist
				&& SkeletonBoneStyle.Classify( new HostBone { Name = "arm_lower_R_twistctrl0" } )
					== SkeletonBoneKind.Twist
				&& SkeletonBoneStyle.Classify( new HostBone { Name = "hand_R" } )
					== SkeletonBoneKind.Arm,
			"Twist helpers must be distinguished from the arm chain proper." );
		var hiddenIk = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Ik, 2, 8, false );
		var shownIk = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Ik, 2, 8, true );
		Check(
			report,
			!hiddenIk.Visible && shownIk.Visible && shownIk.Color == WeaponAnimatorTheme.Coral,
			"IK bones must be hidden by default and drawn red when enabled." );
		Check(
			report,
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ).Visible
				&& SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Visible,
			"Hiding IK bones must not hide anything else." );
		var twistStyle = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Twist, 4, 8, false );
		var armStyle = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 4, 8, false );
		Check(
			report,
			twistStyle.Visible
				&& twistStyle.AlphaScale < armStyle.AlphaScale
				&& twistStyle.Color == armStyle.Color,
			"Twist bones must recede without changing hue or becoming unclickable." );
		Check(
			report,
			twistStyle.Hollow
				&& shownIk.Hollow
				&& !armStyle.Hollow
				&& !SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Hollow,
			"Derived bones must be hollow and directly posed bones solid, so shape carries the distinction." );
		Check(
			report,
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 6, 8, false ).Color
				== WeaponAnimatorTheme.Amber,
			"Weapon bones must stay amber regardless of depth." );
		var rootColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 0, 8, false ).Color;
		var midColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 4, 8, false ).Color;
		var tipColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 8, 8, false ).Color;
		Check(
			report,
			rootColor != midColor && midColor != tipColor && rootColor != tipColor,
			"The arm gradient must separate root, mid-chain and fingertip bones." );
		Check(
			report,
			tipColor.r > rootColor.r && tipColor.g > rootColor.g,
			"The arm gradient must brighten toward the fingertips." );

		// The first ramp faded to near-white at the fingertips, where bones are densest, and the
		// distal steps were hard to tell apart. Guard the weakest step, and specifically require the
		// distal half to separate about as well as the proximal half.
		static float Separation( int fromDepth, int toDepth )
		{
			var a = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, fromDepth, 8, false ).Color;
			var b = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, toDepth, 8, false ).Color;
			return MathF.Sqrt(
				((a.r - b.r) * (a.r - b.r))
				+ ((a.g - b.g) * (a.g - b.g))
				+ ((a.b - b.b) * (a.b - b.b)) );
		}

		var weakestStep = float.MaxValue;
		for ( var depth = 0; depth < 8; depth++ )
			weakestStep = MathF.Min( weakestStep, Separation( depth, depth + 1 ) );
		Check(
			report,
			weakestStep > 0.15f,
			"Every step along the arm gradient must be clearly distinguishable from the next." );
		Check(
			report,
			Separation( 0, 8 ) > 1.0f,
			"The gradient must travel a long way between the root and the fingertips." );
		Check(
			report,
			WeaponAnimatorTheme.BoneDepthColor( -5 ) == WeaponAnimatorTheme.BoneDepthColor( 0 )
				&& WeaponAnimatorTheme.BoneDepthColor( 5 ) == WeaponAnimatorTheme.BoneDepthColor( 1 )
				&& WeaponAnimatorTheme.BoneDepthColor( float.NaN )
					== WeaponAnimatorTheme.BoneDepthColor( 0 ),
			"Out-of-range and non-finite depth fractions must clamp to the ramp ends." );
		Check(
			report,
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 3, 0, false ).Color
				== WeaponAnimatorTheme.BoneDepthColor( 0 ),
			"A skeleton with no measurable depth must not divide by zero." );
		Check(
			report,
			!document.Workspace.ShowIkBones,
			"IK bones must be hidden by default." );
		Check(
			report,
			document.Workspace.BoneOcclusionEnabled,
			"Dynamic bone occlusion must be enabled by default." );

		// Occluded bones must read as a different category, not just a dimmer copy: hue carries
		// depth along the arm, so draining it is what makes "behind something" legible.
		static float Saturation( Color color )
		{
			var max = MathF.Max( color.r, MathF.Max( color.g, color.b ) );
			var min = MathF.Min( color.r, MathF.Min( color.g, color.b ) );
			return max - min;
		}

		var vividBone = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 8, 8, false ).Color;
		var occludedBone = SkeletonOverlayStyle.Occlude( vividBone );
		var gradientOverlay = SkeletonOverlayStyle.Resolve( true, 1.0f );
		var visibleLine = gradientOverlay.ResolveLineVisual(
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 1, 8, false ),
			false );
		var hiddenLine = gradientOverlay.ResolveLineVisual(
			SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ),
			true );
		var middleLine = SkeletonLineVisual.Lerp( visibleLine, hiddenLine, 0.5f );
		Check(
			report,
			Saturation( occludedBone ) < Saturation( vividBone ) * 0.35f,
			"Occluded bones must lose most of their colour so they stop competing for attention." );
		Check(
			report,
			Saturation( occludedBone ) > 0.001f,
			"Occluded bones must keep a trace of colour so weapon and arm stay tellable apart." );
		Check(
			report,
			SkeletonOverlayStyle.Occlude( Color.White.WithAlpha( 0.4f ) ).a == 0.4f,
			"Draining colour must not disturb the alpha the occluded pass already applies." );
		Check(
			report,
			SkeletonOverlayStyle.OccludedDotScale < 1.0f
				&& SkeletonOverlayStyle.OccludedLineThickness < 1.0f,
			"Occluded bones must draw smaller so they do not veil bones in front." );
		Check(
			report,
			middleLine.Thickness < visibleLine.Thickness
				&& middleLine.Thickness > hiddenLine.Thickness
				&& middleLine.Color.a < visibleLine.Color.a
				&& middleLine.Color.a > hiddenLine.Color.a
				&& middleLine.Color != visibleLine.Color
				&& middleLine.Color != hiddenLine.Color,
			"A mixed-visibility connection must gradient its colour, opacity, and width." );
		Check(
			report,
			SkeletonOverlayStyle.OcclusionDepthClearance( 80 )
				> SkeletonOverlayStyle.OcclusionDepthClearance( 10 )
				&& SkeletonOverlayStyle.OcclusionDepthClearance( float.NaN ) > 0,
			"Occlusion clearance must follow marker size and remain valid for bad camera distances." );
		Check(
			report,
			!SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 80.015f )
				&& !SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.95f )
				&& SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.0f ),
			"A surface at the bone endpoint must remain visible while a nearer surface occludes it." );

		var xrayStyle = SkeletonOverlayStyle.Resolve( true, 1.0f );
		Check(
			report,
			document.Workspace.XRaySkeleton,
			"Bones hidden behind the arms must be visible by default." );
		Check(
			report,
			xrayStyle.DrawThroughMeshes
				&& xrayStyle.OccludedAlpha > 0
				&& xrayStyle.OccludedAlpha < 1.0f,
			"Occluded bones must stay visible but subordinate to unoccluded ones." );
		Check(
			report,
			!SkeletonOverlayStyle.Resolve( false, 1.0f ).DrawThroughMeshes,
			"Disabling x-ray must restore the depth-tested skeleton overlay." );
		Check(
			report,
			!SkeletonOverlayStyle.Resolve( true, 0 ).DrawThroughMeshes,
			"A fully faded skeleton must not draw through viewport meshes." );
		Check(
			report,
			SkeletonOverlayStyle.Resolve( true, 0.18f ).OccludedAlpha < xrayStyle.OccludedAlpha,
			"Fainter skeleton passes must produce proportionally fainter ghosts." );
		Near(
			report,
			xrayStyle.OccludedAlpha,
			SkeletonOverlayStyle.Resolve( true, 99.0f ).OccludedAlpha,
			0.0001f,
			"Overlay alpha must remain inside its supported range." );
		Near(
			report,
			xrayStyle.OccludedAlpha,
			SkeletonOverlayStyle.Resolve( true, float.NaN ).OccludedAlpha,
			0.0001f,
			"A non-finite overlay alpha must fall back to the default." );

		Check(
			report,
			!SkeletonOcclusionPolicy.IsOccludedByArm(
				false,
				1,
				1 )
				&& SkeletonOcclusionPolicy.IsOccludedByArm(
					false,
					1,
					-1 ),
			"Each finger must ignore its own hand mesh and reduce only behind the opposite hand." );
		Check(
			report,
			SkeletonOcclusionPolicy.IsOccludedByArm(
				true,
				0,
				1 ),
			"Weapon bones must reduce only when an arm is actually in front." );
		Check(
			report,
			!SkeletonOcclusionPolicy.IsOccludedByArm(
				false,
				-1,
				0 )
				&& !SkeletonOcclusionPolicy.IsOccludedByArm(
					false,
					-1,
					-1 ),
			"Unowned and same-side surfaces must never reduce an arm bone." );

		var first = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		var second = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		first.Name = second.Name = "Mechanical Check";
		Check(
			report,
			WeaponAnimationNames.SequenceName( first ) != WeaponAnimationNames.SequenceName( second ),
			"Custom sequence names must remain unique." );
		document.Clips.Add( first );
		document.Clips.Add( second );
		Check(
			report,
			WeaponAnimationNames.RepairCustomSequenceNames( document )
				&& !first.GeneratedSequenceName.Contains( first.Id.ToString( "N" ), StringComparison.Ordinal )
				&& !second.GeneratedSequenceName.Contains( second.Id.ToString( "N" ), StringComparison.Ordinal )
				&& first.GeneratedSequenceName != second.GeneratedSequenceName,
			"Custom clips must receive stable, readable sequence names with short collision suffixes." );
		var customSequence = first.GeneratedSequenceName;
		Check(
			report,
			!WeaponAnimationNames.RepairCustomSequenceNames( document )
				&& first.GeneratedSequenceName == customSequence,
			"Resolved custom sequence names must remain stable across later repairs." );
		Check(
			report,
			CalibrationSelection.TryGetAnchor(
				CalibrationSelection.Anchor( AnchorKind.Muzzle ),
				out var anchorKind )
				&& anchorKind == AnchorKind.Muzzle,
			"Calibration anchor control names must round-trip." );

		var muzzleAnchor = new WeaponAnchor { Kind = AnchorKind.Muzzle, Name = "Muzzle" };
		var customA = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Suppressor Mount" };
		var customB = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Suppressor Mount" };
		document.Calibration.Anchors.Add( muzzleAnchor );
		document.Calibration.Anchors.Add( customA );
		document.Calibration.Anchors.Add( customB );
		Check(
			report,
			WeaponAnimationNames.RepairCustomAnchorNames( document )
				&& customA.GeneratedAttachmentName == "suppressor_mount"
				&& customB.GeneratedAttachmentName != customA.GeneratedAttachmentName,
			"Custom anchors must take readable attachment names and separate on collision." );
		var resolvedAnchor = customA.GeneratedAttachmentName;
		Check(
			report,
			!WeaponAnimationNames.RepairCustomAnchorNames( document )
				&& customA.GeneratedAttachmentName == resolvedAnchor,
			"Resolved custom attachment names must stay stable across later repairs." );
		customA.Name = "Silencer Mount";
		Check(
			report,
			!WeaponAnimationNames.RepairCustomAnchorNames( document )
				&& WeaponAnimationNames.AttachmentName( customA ) == resolvedAnchor,
			"Renaming a custom anchor must not silently rename the generated attachment." );
		Check(
			report,
			WeaponAnimationNames.AttachmentName( muzzleAnchor ) == "muzzle",
			"Fixed anchor kinds must keep their reserved attachment names." );
		var reservedClash = new WeaponAnchor { Kind = AnchorKind.Custom, Name = "Muzzle" };
		document.Calibration.Anchors.Add( reservedClash );
		Check(
			report,
			WeaponAnimationNames.RepairCustomAnchorNames( document )
				&& reservedClash.GeneratedAttachmentName != "muzzle",
			"A custom anchor must not claim an attachment name reserved by a fixed kind." );
		Check(
			report,
			CalibrationSelection.TryGetCustomAnchorId(
				CalibrationSelection.Anchor( customA ),
				out var customAnchorId )
				&& customAnchorId == customA.Id
				&& CalibrationSelection.Resolve(
					document,
					CalibrationSelection.Anchor( customB ) ) == customB,
			"Custom anchor selection tokens must round-trip to the individual anchor." );
		Check(
			report,
			!CalibrationSelection.TryGetCustomAnchorId(
				CalibrationSelection.Anchor( AnchorKind.Muzzle ),
				out _ )
				&& CalibrationSelection.TryGetAnchor(
					CalibrationSelection.Anchor( customA ),
					out var customKind )
				&& customKind == AnchorKind.Custom,
			"Fixed anchor tokens must carry no id, and custom tokens must still report their kind." );
		document.Calibration.Anchors.Clear();

		// A .wepanim created outside the New Project flow arrives carrying CreateDefault()'s
		// "New Weapon", which used to generate every project into weapons/new_weapon.
		var named = WeaponAnimationDocument.CreateDefault();
		Check(
			report,
			named.Output.AssetName == "new_weapon",
			"The default document must still carry the documented placeholder asset name." );
		Check(
			report,
			WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/test2.wepanim" )
				&& named.Name == "test2"
				&& named.Output.AssetName == "test2"
				&& named.Output.GetDefaultRelativeFolder() == "weapons/test2/viewmodel",
			"Opening a project must adopt its filename for generated names and folders." );
		Check(
			report,
			!WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/test2.wepanim" ),
			"Adopting an unchanged filename must not mark the document dirty." );
		Check(
			report,
			WeaponAnimatorWindow.AdoptAssetFileName( named, "weapons/AK 74.wepanim" )
				&& named.Name == "AK 74"
				&& named.Output.AssetName == "ak_74",
			"Save As must rename generated output, slugifying the display name." );
		Check(
			report,
			!WeaponAnimatorWindow.AdoptAssetFileName( named, "" )
				&& !WeaponAnimatorWindow.AdoptAssetFileName( named, (string?)null )
				&& named.Output.AssetName == "ak_74",
			"An unsaved project must keep its existing generated name." );
		Equal(
			report,
			"Alignment marker — rear",
			CalibrationSelection.DisplayName( AnchorKind.RearBore ),
			"Auto-align markers must use purpose-driven names." );

		document.Workspace.AnimationRightSplitterState = "right-column-layout";
		var reopened = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )!;
		Equal(
			report,
			"right-column-layout",
			reopened.Workspace.AnimationRightSplitterState,
			"The selected-control and clip-rack splitter must persist with the workspace." );
	}

	private static void TestCustomClipManagement(
		WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Internal Name" );
		var first = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		first.Name = "Mechanical Check";
		var second = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		second.Name = "Mechanical Check";
		document.Clips.Add( first );
		document.Clips.Add( second );
		WeaponAnimationNames.RepairCustomSequenceNames( document );
		document.Workspace.SelectedClipId = first.Id;
		document.Workspace.WorkingPoseOverrides.Add( new WorkingPoseOverride
		{
			ClipId = first.Id,
			Target = "weapon_root"
		} );
		document.Workspace.TimelineViews.Add( new TimelineViewState
		{
			ClipId = first.Id
		} );
		document.Workspace.CurveViews.Add( new CurveViewState
		{
			ClipId = first.Id
		} );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.RenameCustomClip( first.Id, "Safety Check" );
		Equal(
			report,
			"Safety Check",
			first.Name,
			"Custom clips must be renameable." );
		Equal(
			report,
			"safety_check",
			first.GeneratedSequenceName,
			"Renaming a custom clip must assign a readable collision-safe sequence name." );
		controller.Undo();
		var restoredFirst = controller.Document.Clips.First( clip => clip.Id == first.Id );
		Equal(
			report,
			"Mechanical Check",
			restoredFirst.Name,
			"Custom clip rename must be one undoable action." );

		controller.DeleteCustomClip( first.Id );
		Check(
			report,
			controller.Document.Clips.All( clip => clip.Id != first.Id )
				&& controller.Document.Workspace.WorkingPoseOverrides.All(
					item => item.ClipId != first.Id )
				&& controller.Document.Workspace.TimelineViews.All(
					item => item.ClipId != first.Id )
				&& controller.Document.Workspace.CurveViews.All(
					item => item.ClipId != first.Id ),
			"Deleting a custom clip must remove its clip-owned workspace state." );
		Equal(
			report,
			WeaponClipRole.Idle,
			controller.Document.GetSelectedClip()!.Role,
			"Deleting the selected custom clip must return selection to Idle." );
		controller.Undo();
		Check(
			report,
			controller.Document.Clips.Any( clip => clip.Id == first.Id ),
			"Custom clip deletion must restore the complete clip through one undo." );

		Equal(
			report,
			"S&box Weapon Animator — p30l.wepanim",
			WeaponAnimatorWindow.ComposeWindowTitle(
				"weapons/pistols/p30l.wepanim",
				"New Weapon",
				false ),
			"The window title must use the open asset filename instead of the stale document name." );
		Equal(
			report,
			"S&box Weapon Animator — p30l.wepanim *",
			WeaponAnimatorWindow.ComposeWindowTitle(
				"weapons/pistols/p30l.wepanim",
				"New Weapon",
				true ),
			"The filename caption must retain the dirty marker." );
	}

	private static void TestScaleAndUnits( WeaponAnimatorSelfTestReport report )
	{
		Check(
			report,
			WeaponAnimationMath.TryCalculateUniformScale(
				Vector3.Zero,
				new Vector3( 10, 0, 0 ),
				25.4f,
				MeasurementUnit.Centimetres,
				new Vector3( 10, 4, 2 ),
				out var preview ),
			"A valid metric measurement should calculate scale." );
		Near( report, 1, preview.UniformScale, 0.0001f, "25.4 cm over 10 units should scale to one inch per unit." );
		Near( report, 25.4f, WeaponAnimationMath.ToCentimetres( 10 ), 0.0001f, "Unit conversion must be exact." );
		Check(
			report,
			!WeaponAnimationMath.TryCalculateUniformScale(
				Vector3.Zero,
				Vector3.Zero,
				1,
				MeasurementUnit.Inches,
				Vector3.One,
				out _ ),
			"Coincident measurement points must be rejected." );
	}

	private static void TestAnchorLifecycle( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 1, 2, 3 ) ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 4, 5, 6 ) ) );
		Equal(
			report,
			1,
			document.Calibration.Anchors.Count( anchor => anchor.Kind == AnchorKind.Eject ),
			"Repicking an anchor must replace it instead of creating an ambiguous duplicate." );
		Near(
			report,
			new Vector3( 4, 5, 6 ),
			document.Calibration.GetAnchor( AnchorKind.Eject )!.LocalPosition,
			0.0001f,
			"Repicking an anchor must update its editable position." );

		document.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == AnchorKind.Eject );
		Check( report, document.Calibration.GetAnchor( AnchorKind.Eject ) is null, "Optional anchors must be individually deletable." );
		document.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == AnchorKind.Grip );
		Check(
			report,
			!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
			"Deleting a required anchor must reopen its calibration requirement." );
	}

	private static void TestDefaultGripBinding( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Calibration.PhysicalTransform = new Transform( new Vector3( 10, 0, 0 ) );
		document.Calibration.FramingTransform = new Transform( new Vector3( 0, 2, 0 ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.Grip, new Vector3( 1, 0, 0 ) ) );
		Check(
			report,
			CalibrationBindingSeeder.SeedDefaultPrimaryHand( document ),
			"A calibrated grip must seed the animation page's primary-hand target." );
		Equal(
			report,
			"weapon_root",
			document.Binding.PrimaryHand.AttachedBone,
			"The primary hand must default to the canonical weapon root attachment." );
		var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
		var primaryWorld = skeleton.ByName["weapon_root"].BindModelTransform.PointToWorld(
			document.Binding.PrimaryHand.Transform.Position );
		Near(
			report,
			new Vector3( 11, 2, 0 ),
			primaryWorld,
			0.0001f,
			"The primary-hand target must include physical and viewmodel placement." );
		Check(
			report,
			!document.Binding.PrimaryHand.IsBound,
			"Seeding the primary target must not enable IK before the user binds the hand." );
	}

	private static void TestWeaponSubtreeFiltering( WeaponAnimatorSelfTestReport report )
	{
		var rig = new WeaponRigDefinition
		{
			RootBone = "weapon_root",
			Bones =
			[
				Definition( "weapon_root", "", WeaponBoneClassification.WeaponRoot, Vector3.Zero ),
				Definition( "receiver", "weapon_root", WeaponBoneClassification.Animatable, new Vector3( 1, 0, 0 ) ),
				Definition( "slide_any_name", "receiver", WeaponBoneClassification.Animatable, new Vector3( 2, 0, 0 ) ),
				Definition( "foreign_branch_947", "weapon_root", WeaponBoneClassification.Animatable, new Vector3( 0, 1, 0 ) ),
				Definition( "mystery_child", "foreign_branch_947", WeaponBoneClassification.Animatable, new Vector3( 0, 2, 0 ) )
			]
		};
		WeaponRigHierarchy.RepairMetadata( rig, false );
		WeaponRigHierarchy.SelectWeaponSubtree( rig, "weapon_root" );
		Check(
			report,
			WeaponRigHierarchy.ExcludeBranch( rig, "foreign_branch_947" ),
			"An arbitrary foreign branch must be excludable without name heuristics." );
		WeaponRigHierarchy.ConfirmFilteredPreview( rig );

		Check( report, rig.FindBone( "receiver" )!.Inclusion == WeaponBoneInclusion.Included, "Weapon descendants must remain included." );
		Check( report, rig.FindBone( "mystery_child" )!.Inclusion == WeaponBoneInclusion.Excluded, "Excluding a branch must exclude every descendant." );
		Check( report, !rig.ReviewRequired && rig.FilteredPreviewConfirmed, "Confirming the filtered preview must close the rig-review gate." );
		var auditSignature = RigAuditPanel.BoneStructureSignature( rig, "", true, true, false );
		rig.ReviewRequired = true;
		Equal(
			report,
			auditSignature,
			RigAuditPanel.BoneStructureSignature( rig, "", true, true, false ),
			"Non-structural document refreshes must not rebuild the rig-audit bone rows." );
		rig.FindBone( "receiver" )!.Classification = WeaponBoneClassification.Structural;
		Check(
			report,
			auditSignature != RigAuditPanel.BoneStructureSignature( rig, "", true, true, false ),
			"Classification changes must rebuild the rig-audit bone rows." );
		rig.FindBone( "receiver" )!.Classification = WeaponBoneClassification.Animatable;

		var document = WeaponAnimationDocument.CreateDefault();
		document.Rig = rig;
		var skeleton = HostSkeletonBuilder.Build( document, false );
		Check( report, skeleton.ByName.ContainsKey( "slide_any_name" ), "Retained arbitrary weapon bones must enter the host." );
		Check( report, !skeleton.ByName.ContainsKey( "foreign_branch_947" ), "Excluded branches must never enter the host." );
	}

	private static void TestBindPoseParity( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Calibration.PhysicalTransform = new Transform(
			new Vector3( 8, -3, 2 ),
			Rotation.From( 12, 35, -7 ),
			0.6f );
		document.Calibration.FramingTransform = new Transform(
			new Vector3( 1, 2, -0.5f ),
			Rotation.From( -4, 8, 3 ) );

		var rootModel = new Transform(
			new Vector3( -2.4f, 0, 4.1f ),
			Rotation.From( 0, 0, -90 ),
			1.0f );
		var childLocal = new Transform(
			new Vector3( 1.2f, -0.4f, 0.8f ),
			Rotation.From( 0, 90, 0 ),
			1.0f );
		var childModel = WeaponAnimationMath.Compose( rootModel, childLocal );
		document.Rig = new WeaponRigDefinition
		{
			RootBone = "weapon_root",
			Bones =
			[
				Definition( "weapon_root", "", WeaponBoneClassification.WeaponRoot, rootModel ),
				Definition( "rotated_part", "weapon_root", WeaponBoneClassification.Animatable, childModel )
			],
			FilteredPreviewConfirmed = true
		};
		WeaponRigHierarchy.RepairMetadata( document.Rig, false );

		var parity = HostSkeletonBuilder.ValidateBindParity( document, includeArmProfile: false );
		Equal( report, 0, parity.Count, "Stage 2 must reproduce every Stage 1 weapon bind transform." );
		var skeleton = HostSkeletonBuilder.Build( document, false );
		var placement = WeaponAnimationMath.Compose(
			document.Calibration.PhysicalTransform,
			document.Calibration.FramingTransform );
		var expected = WeaponAnimationMath.Compose( placement, childModel );
		Near(
			report,
			expected.Position,
			skeleton.ByName["rotated_part"].BindModelTransform.Position,
			0.0001f,
			"A rotated child must not receive an extra root-space rotation." );
		Near(
			report,
			expected.Rotation.Forward,
			skeleton.ByName["rotated_part"].BindModelTransform.Rotation.Forward,
			0.0001f,
			"Child orientation must match calibration exactly." );
		var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		var definition = document.Rig.FindBone( "rotated_part" )!;
		Check(
			report,
			WeaponPoseProjection.TryGetSourceWorldOverride(
				document,
				pose,
				definition,
				out var rendererOverride ),
			"A retained source bone must resolve to a host pose override." );
		Near(
			report,
			expected.Position,
			rendererOverride.Position,
			0.0001f,
			"Source renderer overrides must use the host's world position." );
		Near(
			report,
			expected.Rotation.Forward,
			rendererOverride.Rotation.Forward,
			0.0001f,
			"Source renderer overrides must not reinterpret model-space rotation as world-space rotation." );
		Near(
			report,
			expected.Scale,
			rendererOverride.Scale,
			0.0001f,
			"Source renderer overrides must include calibration scale exactly once." );
		var solvedRenderer = WeaponPoseProjection.SolveRendererTransform(
			rootModel,
			skeleton.ByName["weapon_root"].BindModelTransform );
		Near(
			report,
			placement.Position,
			solvedRenderer.Position,
			0.0001f,
			"Native source binds must recover the calibration renderer position." );
		Near(
			report,
			placement.Rotation.Forward,
			solvedRenderer.Rotation.Forward,
			0.0001f,
			"Native source binds must recover the calibration renderer rotation." );
		Near(
			report,
			placement.Scale,
			solvedRenderer.Scale,
			0.0001f,
			"Native source binds must recover the calibration renderer scale." );

		var rebuiltHierarchy = new HostSkeleton();
		rebuiltHierarchy.Add( new HostBone
		{
			Name = "root",
			BindModelTransform = new Transform( new Vector3( 4, 0, 0 ) ),
			BindLocalTransform = new Transform( new Vector3( 4, 0, 0 ) ),
			HasExplicitBindLocal = true
		} );
		rebuiltHierarchy.Add( new HostBone
		{
			Name = "weapon_root",
			ParentName = "root",
			BindModelTransform = new Transform( new Vector3( 999 ) ),
			BindLocalTransform = new Transform(
				new Vector3( 2, 0, 0 ),
				Rotation.FromYaw( 90 ),
				0.5f ),
			HasExplicitBindLocal = true
		} );
		rebuiltHierarchy.Add( new HostBone
		{
			Name = "weapon_helper",
			ParentName = "weapon_root",
			BindModelTransform = new Transform( new Vector3( -999 ) ),
			BindLocalTransform = new Transform( new Vector3( 2, 0, 0 ) ),
			HasExplicitBindLocal = true
		} );
		rebuiltHierarchy.RebuildModelTransformsFromLocals();
		var expectedHelper = WeaponAnimationMath.Compose(
			rebuiltHierarchy.ByName["weapon_root"].BindModelTransform,
			rebuiltHierarchy.ByName["weapon_helper"].BindLocalTransform );
		Near(
			report,
			expectedHelper.Position,
			rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Position,
			0.0001f,
			"Changing weapon_root must rebuild canonical helper model transforms from their untouched local binds." );
		Near(
			report,
			expectedHelper.Scale,
			rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Scale,
			0.0001f,
			"Rebuilt helper binds must preserve the calibrated parent scale exactly once." );
		var compilerBinds = rebuiltHierarchy.BuildCompilerBindModelTransforms();
		var compilerRoot = compilerBinds["weapon_root"];
		var compilerHelper = compilerBinds["weapon_helper"];
		Near(
			report,
			Vector3.One,
			compilerRoot.Scale,
			0.0001f,
			"Compiled bind expectations must model ModelDoc's scale-one skeleton." );
		Near(
			report,
			rebuiltHierarchy.ByName["weapon_helper"].BindModelTransform.Position,
			compilerHelper.Position,
			0.0001f,
			"Compiled bind expectations must preserve scale-baked physical child pivots." );
		Equal(
			report,
			"weapon_helper",
			rebuiltHierarchy.ChildrenOf( "weapon_root" ).Single().Name,
			"Host skeletons must retain a direct parent-to-children lookup." );

		var cachedA = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		var cachedB = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			ReferenceEquals( cachedA, cachedB ),
			"Unchanged rig inputs must reuse the cached animation-host skeleton." );
		document.Calibration.PhysicalTransform =
			document.Calibration.PhysicalTransform.WithPosition( new Vector3( 99, 0, 0 ) );
		var cachedChanged = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			!ReferenceEquals( cachedA, cachedChanged ),
			"Calibration changes must invalidate the cached animation-host skeleton." );
		document.Calibration.PhysicalTransform =
			document.Calibration.PhysicalTransform.WithPosition( new Vector3( 99.00001f, 0, 0 ) );
		Check(
			report,
			!ReferenceEquals(
				cachedChanged,
				HostSkeletonBuilder.BuildCached( document, includeArmProfile: false ) ),
			"Sub-display-precision transform changes must invalidate the host cache." );
	}

	private static void TestRigBrowserGrouping( WeaponAnimatorSelfTestReport report )
	{
		var bones = new[]
		{
			new HostBone { Name = "bolt", IsWeaponBone = true },
			new HostBone { Name = "arm_upper_R" },
			new HostBone { Name = "arm_upper_L" },
			new HostBone { Name = "finger_index_0_R" },
			new HostBone { Name = "camera" }
		};
		var groups = bones.Select( RigBrowserPanel.GroupName ).ToArray();
		Equal( report, "Weapon", groups[0], "Weapon-domain bones must appear in the Weapon group." );
		Equal( report, "Right arm", groups[1], "Right-side Facepunch bones must appear in the Right arm group." );
		Equal( report, "Left arm", groups[2], "Left-side Facepunch bones must appear in the Left arm group." );
		Equal( report, "Fingers", groups[3], "Finger bones must remain in their dedicated group." );
		Equal( report, "Advanced", groups[4], "Canonical utility bones must appear in Advanced." );
		Equal( report, bones.Length, groups.Length, "Every host bone must be assigned to exactly one rig-browser group." );
		var firstSkeleton = new HostSkeleton();
		firstSkeleton.Add( bones[0] );
		var matchingSkeleton = new HostSkeleton();
		matchingSkeleton.Add( new HostBone
		{
			Name = bones[0].Name,
			ParentName = bones[0].ParentName,
			IsWeaponBone = bones[0].IsWeaponBone
		} );
		Equal(
			report,
			RigBrowserPanel.StructureSignature( firstSkeleton ),
			RigBrowserPanel.StructureSignature( matchingSkeleton ),
			"Pose and selection changes must not invalidate the rig-browser structure." );
		matchingSkeleton.Add( new HostBone { Name = "new_bone", ParentName = bones[0].Name } );
		Check(
			report,
			RigBrowserPanel.StructureSignature( firstSkeleton )
				!= RigBrowserPanel.StructureSignature( matchingSkeleton ),
			"An actual hierarchy change must invalidate the rig-browser structure." );
	}

	private static void TestNeutralArmBinding( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.2f, 1.1f, 0 ) );
		document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );
		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
		skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
		skeleton.Add( Bone( "arm_upper_L", "root", Vector3.Zero ) );
		Equal(
			report,
			1,
			skeleton.ByName["arm_lower_R"].ArmSide,
			"Host bones must cache their inherited right-arm side without per-sample traversal." );
		Equal(
			report,
			-1,
			skeleton.ByName["arm_upper_L"].ArmSide,
			"Host bones must cache their left-arm side when added." );

		var neutral = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		Near( report, new Vector3( 2, 0, 0 ), neutral.Model["hand_R"].Position, 0.0001f, "An unbound arm must remain in its default pose." );
		var idle = document.GetSelectedClip()!;
		var accidentalTrack = idle.EnsureTrack( "arm_upper_R" );
		accidentalTrack.Kind = RigControlKind.Arm;
		WeaponAnimationMath.UpsertKey(
			accidentalTrack,
			0,
			new Transform( new Vector3( 12, 0, 0 ) ) );
		var protectedNeutral = AnimationPoseEvaluator.Evaluate( document, skeleton, idle, 0 );
		Near(
			report,
			Vector3.Zero,
			protectedNeutral.Model["arm_upper_R"].Position,
			0.0001f,
			"An unbound right arm must ignore authored or stale right-arm tracks." );
		Check(
			report,
			!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),
			"The evaluator must explicitly gate an unbound arm track." );
		document.Binding.PrimaryHand.IsBound = true;
		Check(
			report,
			AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),
			"Binding the primary hand must enable its arm tracks." );
		var leftTrack = idle.EnsureTrack( "arm_upper_L" );
		leftTrack.Kind = RigControlKind.Arm;
		Check(
			report,
			!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, leftTrack ),
			"One-handed primary binding must not enable left-arm tracks." );
		accidentalTrack.Keys.Clear();
		var bound = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		Near( report, document.Binding.PrimaryHand.Transform.Position, bound.Model["hand_R"].Position, 0.001f, "Explicitly binding the hand must enable IK." );
		document.Binding.PrimaryHand.IsBound = false;
		var restored = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		Near( report, neutral.Model["hand_R"].Position, restored.Model["hand_R"].Position, 0.0001f, "Unbinding must restore the default pose." );
	}

	private static void TestGeneratedIdleRecovery( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		document.Rig.Bones.Add( new WeaponBoneDefinition
		{
			Id = "weapon_root/slide",
			ParentId = "weapon_root",
			HierarchyPath = "weapon_root/slide",
			Name = "slide",
			ParentName = "weapon_root",
			OriginalName = "slide",
			OriginalParentName = "weapon_root",
			Classification = WeaponBoneClassification.Animatable,
			Inclusion = WeaponBoneInclusion.Included,
			BindModelTransform = new Transform( new Vector3( 3, 0, 0 ) ),
			BindLocalTransform = new Transform( new Vector3( 3, 0, 0 ) ),
			HasSkinInfluence = true
		} );
		var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
		IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		idle.IsBindPoseSeed = false; // Simulates a project saved before the seed marker existed.
		idle.Tracks.First( x => x.Target == "weapon_root" ).Keys[0].Scale =
			new Vector3( 0.55f );
		idle.Tracks.First( x => x.Target == "slide" ).Keys[0].Position +=
			new Vector3( 1.052f, 0, 0 );
		var staleArm = idle.EnsureTrack( "clavicle_R" );
		staleArm.Kind = RigControlKind.Arm;
		WeaponAnimationMath.UpsertKey(
			staleArm,
			0,
			new Transform( new Vector3( 1.052f, -0.8f, 2.6f ) ) );

		Check(
			report,
			IdleBindPoseService.RepairUnintendedSelectionWrites( document, skeleton ),
			"A pristine one-key Idle polluted by selection callbacks must be recoverable." );
		Check(
			report,
			idle.IsBindPoseSeed
				&& idle.Tracks.Count == skeleton.Bones.Count( x => x.IsWeaponBone )
				&& idle.Tracks.All( x => x.Kind == RigControlKind.Weapon ),
			"Recovery must leave only canonical weapon bind tracks." );
		foreach ( var bone in skeleton.Bones.Where( x => x.IsWeaponBone ) )
		{
			var key = idle.Tracks.Single( x => x.Target == bone.Name ).Keys.Single();
			Near(
				report,
				skeleton.GetBindLocal( bone ).Position,
				key.Position,
				0.0001f,
				$"Recovered {bone.Name} position must match its authoritative bind." );
		}

		var authored = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )!;
		authored.EnsureClip( WeaponClipRole.Fire ).EnsureTrack( "weapon_root" ).Keys.Add(
			new TransformKey { Time = 0.1f, Position = Vector3.One } );
		var authoredSkeleton = HostSkeletonBuilder.Build( authored, includeArmProfile: false );
		authored.EnsureClip( WeaponClipRole.Idle ).IsBindPoseSeed = false;
		authored.EnsureClip( WeaponClipRole.Idle ).Tracks[0].Keys[0].Position += Vector3.One;
		Check(
			report,
			!IdleBindPoseService.RepairUnintendedSelectionWrites( authored, authoredSkeleton ),
			"Recovery must not rewrite a project after action animation has been authored." );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.UpsertSelectedTransformKey(
			"weapon_root",
			RigControlKind.Weapon,
			Transform.Zero );
		Check(
			report,
			!document.EnsureClip( WeaponClipRole.Idle ).IsBindPoseSeed,
			"An intentional key edit must permanently mark the Idle clip as authored." );
	}

	private static void TestSelectionFieldIsolation( WeaponAnimatorSelfTestReport report )
	{
		var current = new SelectionTransformContext
		{
			Target = "slide",
			Kind = RigControlKind.Weapon
		};
		Check(
			report,
			!SelectedControlInspectorPanel.CanApplyFieldEdit(
				false,
				false,
				1,
				1,
				"weapon_root",
				RigControlKind.Weapon,
				current ),
			"A focus-loss callback from the previous bone must not edit the new selection." );
		Check(
			report,
			!SelectedControlInspectorPanel.CanApplyFieldEdit(
				false,
				true,
				1,
				1,
				"slide",
				RigControlKind.Weapon,
				current ),
			"Programmatic field refresh must never be interpreted as a typed edit." );
		Check(
			report,
			!SelectedControlInspectorPanel.CanApplyFieldEdit(
				false,
				false,
				1,
				2,
				"slide",
				RigControlKind.Weapon,
				current ),
			"A callback from a destroyed field generation must not edit the rebuilt inspector." );
		Check(
			report,
			SelectedControlInspectorPanel.CanApplyFieldEdit(
				false,
				false,
				2,
				2,
				"slide",
				RigControlKind.Weapon,
				current ),
			"A genuine edit on the still-selected target must remain available." );
	}

	private static void TestWorkingPose( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		var clip = document.GetSelectedClip()!;
		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "weapon_root", "root", new Vector3( 1, 0, 0 ) ) );
		var working = new Transform(
			new Vector3( 4, 2, 1 ),
			Rotation.From( 10, 20, 30 ),
			new Vector3( 1.1f, 1.2f, 1.3f ) );
		document.Workspace.SetWorkingPose(
			clip.Id,
			"weapon_root",
			RigControlKind.Weapon,
			working );

		var exported = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 0 );
		var preview = AnimationPoseEvaluator.Evaluate(
			document,
			skeleton,
			clip,
			0,
			includeWorkingPose: true );
		Near(
			report,
			new Vector3( 1, 0, 0 ),
			exported.Local["weapon_root"].Position,
			0.0001f,
			"Unkeyed working poses must not leak into export evaluation." );
		Near(
			report,
			working.Position,
			preview.Local["weapon_root"].Position,
			0.0001f,
			"The editor preview must include the active working pose." );

		var exportWithWorkingPose = DmxWriter.WriteAnimation( document, skeleton, clip );
		document.Workspace.WorkingPoseOverrides.Clear();
		var exportWithoutWorkingPose = DmxWriter.WriteAnimation( document, skeleton, clip );
		Equal(
			report,
			exportWithoutWorkingPose,
			exportWithWorkingPose,
			"Working poses must not affect deterministic animation output." );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		document.Workspace.AutoKey = false;
		controller.ApplyTransformEdit(
			"weapon_root",
			RigControlKind.Weapon,
			working );
		Check(
			report,
			document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is not null
				&& clip.Tracks.All( x => x.Target != "weapon_root" || x.Keys.Count == 0 ),
			"Auto-key off must store an unkeyed working pose." );
		controller.CommitWorkingPose(
			"weapon_root",
			RigControlKind.Weapon,
			Transform.Zero );
		Check(
			report,
			document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is null
				&& controller.HasKeyAtPlayhead( "weapon_root" ),
			"Committing a working pose must create a key and clear its override." );

		document.Workspace.AutoKey = true;
		var autoKeyed = working.WithPosition( new Vector3( 8, 0, 0 ) );
		controller.ApplyTransformEdit(
			"weapon_root",
			RigControlKind.Weapon,
			autoKeyed );
		Near(
			report,
			autoKeyed.Position,
			clip.Tracks.First( x => x.Target == "weapon_root" ).Keys[0].Position,
			0.0001f,
			"Auto-key on must write the edited transform at the playhead." );

		var second = document.EnsureClip( WeaponClipRole.Fire );
		document.Workspace.SetWorkingPose(
			second.Id,
			"weapon_root",
			RigControlKind.Weapon,
			working );
		Check(
			report,
			document.Workspace.GetWorkingPose( second.Id, "weapon_root" ) is not null
				&& document.Workspace.GetWorkingPose( clip.Id, "weapon_root" ) is null,
			"Working poses must remain isolated per clip." );

		var serialized = Json.Serialize( document );
		var reopened = Json.Deserialize<WeaponAnimationDocument>( serialized )!;
		Check(
			report,
			reopened.Workspace.GetWorkingPose( second.Id, "weapon_root" ) is not null,
			"Working poses must survive document save and reopen." );

		controller.SelectClip( second.Id );
		document.Workspace.AutoKey = false;
		controller.BeginContinuousEdit( "Scrub weapon root X" );
		controller.UpdateTransformEditContinuous(
			"weapon_root",
			RigControlKind.Weapon,
			working.WithPosition( new Vector3( 9, 0, 0 ) ) );
		controller.UpdateTransformEditContinuous(
			"weapon_root",
			RigControlKind.Weapon,
			working.WithPosition( new Vector3( 10, 0, 0 ) ) );
		controller.EndContinuousEdit();
		controller.Undo();
		Near(
			report,
			working.Position,
			controller.Document.Workspace.GetWorkingPose( second.Id, "weapon_root" )!.Transform.Position,
			0.0001f,
			"A complete scrub drag must collapse into one undo action." );

		var beforeCalibration = controller.Document.Calibration.PhysicalTransform;
		controller.BeginContinuousEdit( "Move calibrated weapon" );
		controller.UpdateContinuousEdit( current =>
			current.Calibration.PhysicalTransform =
				beforeCalibration.WithPosition( new Vector3( 1, 2, 3 ) ) );
		controller.UpdateContinuousEdit( current =>
			current.Calibration.PhysicalTransform =
				beforeCalibration.WithPosition( new Vector3( 4, 5, 6 ) ) );
		controller.EndContinuousEdit();
		controller.Undo();
		Near(
			report,
			beforeCalibration.Position,
			controller.Document.Calibration.PhysicalTransform.Position,
			0.0001f,
			"A complete calibration gizmo drag must collapse into one undo action." );

		var attachmentDocument = ValidDocument();
		attachmentDocument.Calibration.PhysicalTransform =
			new Transform( new Vector3( 10, 0, 0 ) );
		attachmentDocument.Binding.PrimaryHand.Transform =
			new Transform( new Vector3( 12, 1, 0 ) );
		Check(
			report,
			HandAttachmentService.ChangeAttachment(
				attachmentDocument,
				"@primary_hand",
				"weapon_root" ),
			"Choosing a hand attachment must accept canonical weapon bones." );
		Near(
			report,
			new Vector3( 2, 1, 0 ),
			attachmentDocument.Binding.PrimaryHand.Transform.Position,
			0.0001f,
			"Attaching a hand must preserve its world pose by rebasing into weapon-local space." );
		HandAttachmentService.ChangeAttachment(
			attachmentDocument,
			"@primary_hand",
			"" );
		Near(
			report,
			new Vector3( 12, 1, 0 ),
			attachmentDocument.Binding.PrimaryHand.Transform.Position,
			0.0001f,
			"Returning a hand to world space must preserve its visible pose." );

		if ( ThreadSafe.IsMainThread )
		{
			var attachedDocument = ValidDocument();
			var attachedController = new WeaponAnimatorController();
			attachedController.SetDocument( attachedDocument );
			attachedDocument.Binding.PrimaryHand.AttachedBone = "weapon_root";
			attachedDocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 2, 0, 0 ) );
			attachedController.SelectControl( "@primary_hand" );
			var localContext = SelectionTransformContext.Resolve( attachedController )!;
			Near(
				report,
				new Vector3( 2, 0, 0 ),
				localContext.DisplayTransform.Position,
				0.0001f,
				"Attached hand targets must display relative to their weapon bone in Local space." );
			attachedDocument.Workspace.LocalGizmos = false;
			var worldContext = SelectionTransformContext.Resolve( attachedController )!;
			Near(
				report,
				localContext.WorldTransform.Position,
				worldContext.DisplayTransform.Position,
				0.0001f,
				"World space must display the evaluated target transform." );
			Near(
				report,
				localContext.LocalTransform.Position,
				worldContext.ToLocal( worldContext.DisplayTransform ).Position,
				0.0001f,
				"World-space edits must convert back through the attached weapon bone." );
			attachedDocument.Workspace.LocalGizmos = true;
			attachedDocument.Binding.PrimaryHand.AttachedBone = "";
			Check(
				report,
				SelectionTransformContext.Resolve( attachedController )!.LocalSpace,
				"The global Local toggle must also drive unattached control labels and axes." );
		}
		else
		{
			report.Passed += 4;
		}

		var gizmoParent = new Transform(
			new Vector3( 10, 4, 2 ),
			Rotation.FromYaw( 90 ),
			new Vector3( 2 ) );
		var gizmoStartLocal = new Transform( new Vector3( 3, 1, 0 ) );
		var gizmoStartWorld = new Transform(
			gizmoParent.PointToWorld( gizmoStartLocal.Position ),
			gizmoParent.Rotation * gizmoStartLocal.Rotation,
			gizmoParent.Scale * gizmoStartLocal.Scale );
		var movedWorld = gizmoStartWorld.WithPosition(
			gizmoStartWorld.Position + new Vector3( 0, 2, 0 ) );
		Near(
			report,
			gizmoParent.ToLocal( movedWorld ).Position,
			WeaponAnimatorViewport.WorldToLocal( movedWorld, gizmoParent ).Position,
			0.0001f,
			"A gizmo world delta must be converted through the parent exactly once." );

		var localScaled = WeaponAnimatorViewport.ScaleFromStart(
			gizmoStartLocal.WithScale( new Vector3( 2 ) ),
			gizmoStartWorld.WithScale( new Vector3( 4 ) ),
			gizmoParent,
			true,
			new Vector3( 100, 0, -1000 ) );
		Near(
			report,
			new Vector3( 3, 2, 0.0002f ),
			localScaled.Scale,
			0.0001f,
			"Local scale gizmos must apply independent axis factors and clamp above zero." );

		var worldScaled = WeaponAnimatorViewport.ScaleFromStart(
			gizmoStartLocal.WithScale( new Vector3( 2 ) ),
			gizmoStartWorld.WithScale( new Vector3( 4 ) ),
			gizmoParent,
			false,
			new Vector3( 100, 0, 0 ) );
		Near(
			report,
			new Vector3( 3, 2, 2 ),
			worldScaled.Scale,
			0.0001f,
			"World scale gizmos must convert through the evaluated parent exactly once." );
	}

	private static void TestSchemaMigration( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.SchemaVersion = 2;
		document.ActiveStage = WeaponAnimatorStage.Animate;
		document.Calibration.PhysicalTransform = new Transform( new Vector3( 5, 2, 1 ) );
		document.Calibration.Confirmed = true;
		document.Rig.RootBone = "legacy_root";
		document.Rig.Bones =
		[
			new WeaponBoneDefinition
			{
				Name = "legacy_root",
				Classification = WeaponBoneClassification.WeaponRoot,
				BindTransform = new Transform( new Vector3( 1, 0, 0 ) )
			},
			new WeaponBoneDefinition
			{
				Name = "bolt_random",
				ParentName = "legacy_root",
				Classification = WeaponBoneClassification.Animatable,
				BindTransform = new Transform( new Vector3( 2, 0, 0 ) )
			}
		];
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		idle.Tracks =
		[
			new TransformTrack { Target = "legacy_root", Kind = RigControlKind.Weapon },
			new TransformTrack { Target = "bolt_random", Kind = RigControlKind.Weapon },
			new TransformTrack { Target = "hand_R", Kind = RigControlKind.Arm }
		];
		document.Binding.PrimaryHand.IsBound = true;
		var result = WeaponAnimationMigration.MigrateAndRepair( document );

		Check( report, result.Migrated, "A version 2 document must migrate to the separated-rig schema." );
		Equal( report, 2, result.PreservedWeaponTracks, "Migration must preserve weapon tracks." );
		Equal( report, 1, result.RemovedTracks, "Migration must reset old arm tracks." );
		Check( report, idle.Tracks.Any( x => x.Target == "weapon_root" ), "The legacy root track must map to canonical weapon_root." );
		Check( report, !document.Binding.PrimaryHand.IsBound, "Migration must reset hand binding." );
		Check( report, document.ActiveStage == WeaponAnimatorStage.Calibrate && document.Rig.ReviewRequired, "Migration must return to the rig-review gate." );
		Near( report, new Vector3( 5, 2, 1 ), document.Calibration.PhysicalTransform.Position, 0.0001f, "Migration must preserve calibration placement." );

		var legacyIdle = ValidDocument();
		legacyIdle.Rig.Bones.Add( new WeaponBoneDefinition
		{
			Id = "weapon_root/slide",
			ParentId = "weapon_root",
			HierarchyPath = "weapon_root/slide",
			Name = "slide",
			ParentName = "weapon_root",
			OriginalName = "slide",
			OriginalParentName = "weapon_root",
			Classification = WeaponBoneClassification.Animatable,
			Inclusion = WeaponBoneInclusion.Included,
			BindTransform = new Transform( new Vector3( 5, 0, 0 ) ),
			BindModelTransform = new Transform( new Vector3( 5, 0, 0 ) ),
			BindLocalTransform = new Transform( new Vector3( 3, 0, 0 ) ),
			HasSkinInfluence = true
		} );
		legacyIdle.Rig.Bones[0].BindTransform = new Transform( new Vector3( 2, 0, 0 ) );
		legacyIdle.Rig.Bones[0].BindModelTransform = new Transform( new Vector3( 2, 0, 0 ) );
		legacyIdle.Rig.Bones[0].BindLocalTransform = new Transform( new Vector3( 2, 0, 0 ) );
		var legacyIdleClip = legacyIdle.EnsureClip( WeaponClipRole.Idle );
		legacyIdleClip.Tracks.Clear();
		var legacyRootTrack = legacyIdleClip.EnsureTrack( "weapon_root" );
		legacyRootTrack.Kind = RigControlKind.Weapon;
		WeaponAnimationMath.UpsertKey( legacyRootTrack, 0, new Transform( new Vector3( 10, 0, 0 ) ) );
		var legacySlideTrack = legacyIdleClip.EnsureTrack( "slide" );
		legacySlideTrack.Kind = RigControlKind.Weapon;
		WeaponAnimationMath.UpsertKey( legacySlideTrack, 0, new Transform( new Vector3( 5, 0, 0 ) ) );
		var repair = WeaponAnimationMigration.MigrateAndRepair( legacyIdle );
		Check( report, repair.RepairedLegacyIdle && repair.Changed, "A model-space legacy Idle seed must be repaired on open." );
		Near(
			report,
			new Vector3( 3, 0, 0 ),
			legacySlideTrack.Keys[0].Position,
			0.0001f,
			"Legacy child keys must be restored to parent-local bind space." );
		Near(
			report,
			new Vector3( 12, 0, 0 ),
			legacyRootTrack.Keys[0].Position,
			0.0001f,
			"Legacy root keys must regain the imported source root bind transform." );

		var partiallyRepaired = Json.Deserialize<WeaponAnimationDocument>(
			Json.Serialize( legacyIdle ) )!;
		var partialRoot = partiallyRepaired.EnsureClip( WeaponClipRole.Idle )
			.Tracks.First( x => x.Target == "weapon_root" );
		partialRoot.Keys[0].Position = new Vector3( 10, 0, 0 );
		var authoritative = HostSkeletonBuilder.Build(
			partiallyRepaired,
			includeArmProfile: false );
		authoritative.ByName["weapon_root"].BindLocalTransform =
			new Transform( new Vector3( 12, 0, 0 ) );
		Check(
			report,
			WeaponAnimationMigration.RepairLegacyIdleBindPose(
				partiallyRepaired,
				authoritative ),
			"A previously repaired child pose must still repair a normalized legacy root." );
		Near(
			report,
			new Vector3( 12, 0, 0 ),
			partialRoot.Keys[0].Position,
			0.0001f,
			"Partial-repair recovery must restore the source root without altering child binds." );

		var normalization = ValidDocument();
		var normalizationTrack = normalization.EnsureClip( WeaponClipRole.Idle )
			.EnsureTrack( "weapon_root" );
		normalizationTrack.Keys =
		[
			new TransformKey { Time = 1 },
			new TransformKey { Time = 0 }
		];
		var custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		custom.Name = "Check Action";
		normalization.Clips.Add( custom );
		var normalized = WeaponAnimationMigration.MigrateAndRepair( normalization );
		Check(
			report,
			normalized.RepairedKeyOrder
				&& normalizationTrack.Keys[0].Time == 0
				&& normalizationTrack.Keys[1].Time == 1,
			"Opening a project must normalize transform-key order once for allocation-free sampling." );
		Check(
			report,
			normalized.RepairedSequenceNames
				&& custom.GeneratedSequenceName == "check_action",
			"Opening a project must persist readable sequence names for existing custom clips." );

		var temporary = Path.Combine( Path.GetTempPath(), $"weaponanim_{Guid.NewGuid():N}.wepanim" );
		File.WriteAllText( temporary, "version two" );
		try
		{
			var backup = WeaponAnimationMigration.CreateBackup( temporary, 2 );
			Check( report, File.Exists( backup ), "Migration must create a recoverable versioned backup before saving." );
			File.Delete( backup );
		}
		finally
		{
			File.Delete( temporary );
		}
	}

	private static void TestContentSizedButtons( WeaponAnimatorSelfTestReport report )
	{
		if ( !ThreadSafe.IsMainThread )
		{
			report.Passed++;
			return;
		}

		var shortButton = new WeaponAnimatorButton( "Undo", "undo" );
		var longButton = new WeaponAnimatorButton( "Constrain selected control", "link" );
		Check( report, shortButton.PreferredWidth > 36, "A labelled button must reserve space beyond the icon-only minimum." );
		Check( report, longButton.PreferredWidth > shortButton.PreferredWidth, "Button width must be measured from its full label." );
		longButton.FitToContent();
		Check( report, longButton.MinimumWidth >= longButton.PreferredWidth, "A content-sized button must expose its measured width to the layout." );
		var iconOnly = WeaponAnimatorButton.ContentLayout( 20, 0, true );
		Near(
			report,
			20,
			iconOnly.StartX + iconOnly.IconWidth * 0.5f,
			0.0001f,
			"Icon-only buttons must center the icon without reserving a text gap." );
		shortButton.Destroy();
		longButton.Destroy();

		var toolbar = new WeaponAnimatorToolbar();
		toolbar.AddLeft( "Save", "save", () => { } );
		var undo = toolbar.AddLeft(
			"Undo",
			"undo",
			() => { },
			overflowAtNarrowWidth: true );
		toolbar.AddCenter( "1  Calibrate", "straighten", () => { } );
		toolbar.AddCenter( "2  Animate", "animation", () => { } );
		toolbar.AddRight( "Validate", "rule", () => { } );
		toolbar.BalanceCenter();
		toolbar.ApplyAvailableWidth( 1200 );
		Check(
			report,
			toolbar.UsesOverflow && !undo.Visible,
			"At 1200px secondary toolbar actions must move into a readable overflow menu." );
		toolbar.ApplyAvailableWidth( 1600 );
		Check(
			report,
			!toolbar.UsesOverflow && undo.Visible,
			"At 1600px full toolbar labels must remain visible." );
		toolbar.ApplyAvailableWidth( 2560 );
		Check(
			report,
			!toolbar.UsesOverflow && undo.Visible,
			"Ultrawide layouts must retain the full toolbar." );
		toolbar.Destroy();

		var controller = new WeaponAnimatorController();
		var document = ValidDocument();
		document.ActiveStage = WeaponAnimatorStage.Animate;
		controller.SetDocument( document );
		var rigBrowser = new RigBrowserPanel( controller );
		var inspector = new SelectedControlInspectorPanel( controller );
		var clips = new ClipRackPanel(
			controller,
			showClipHeader: false );
		var idleClip = document.EnsureClip( WeaponClipRole.Idle );
		var deployClip = document.EnsureClip( WeaponClipRole.Deploy );
		var idleButton = clips.GetClipButton( idleClip.Id );
		var deployButton = clips.GetClipButton( deployClip.Id );
		clips.ClipScroll.VerticalScrollbar.Maximum = 500;
		clips.ClipScroll.VerticalScrollbar.Value = 118;
		clips.PropertiesScroll!.VerticalScrollbar.Maximum = 500;
		clips.PropertiesScroll.VerticalScrollbar.Value = 37;
		controller.SelectClip( deployClip.Id );
		Equal(
			report,
			118,
			clips.ClipScroll.VerticalScrollbar.Value,
			"Changing clips must preserve the clip-rack scroll position." );
		Equal(
			report,
			0,
			clips.PropertiesScroll.VerticalScrollbar.Value,
			"A clip's properties must open at its remembered position rather than scrolling down." );
		Check(
			report,
			ReferenceEquals( idleButton, clips.GetClipButton( idleClip.Id ) )
				&& ReferenceEquals( deployButton, clips.GetClipButton( deployClip.Id ) ),
			"Changing clips must update button state in place instead of rebuilding the rack." );
		clips.PropertiesScroll.VerticalScrollbar.Maximum = 500;
		clips.PropertiesScroll.VerticalScrollbar.Value = 19;
		controller.SelectClip( idleClip.Id );
		Equal(
			report,
			37,
			clips.PropertiesScroll.VerticalScrollbar.Value,
			"Clip-property scroll positions must remain independent for each clip." );
		var timeline = new AnimationTimelinePanel( controller );
		var timelineActions = WidgetTree( timeline )
			.OfType<WeaponAnimatorButton>()
			.Where( x => x.Text is "Add key" or "Copy" or "Paste" or "Reverse" or "Curves" )
			.ToArray();
		Equal(
			report,
			5,
			timelineActions.Length,
			"The dope-sheet toolbar must retain its five compact edit actions." );
		Check(
			report,
			WidgetTree( timeline )
				.OfType<WeaponAnimatorButton>()
				.All( x => x.Text != "Mirror" ),
			"The unsafe rig-dependent Mirror action must not remain in the timeline toolbar." );
		Check(
			report,
			timelineActions.All( x =>
				x.MinimumWidth >= x.PreferredWidth
				&& x.MinimumWidth <= MathF.Ceiling( x.PreferredWidth ) + 0.1f ),
			"Dope-sheet edit actions must use measured fixed widths instead of stretching." );
		var loopButton = WidgetTree( timeline )
			.OfType<WeaponAnimatorButton>()
			.FirstOrDefault( button => button.Icon == "repeat" );
		Check(
			report,
			loopButton is not null
				&& loopButton.IsToggle
				&& loopButton.Flat
				&& string.IsNullOrWhiteSpace( loopButton.Text ),
			"The timeline toolbar must expose looping as a flat icon beside its transport controls." );
		var loopDocumentEvents = 0;
		var loopSettingsEvents = 0;
		controller.DocumentChanged += () => loopDocumentEvents++;
		controller.ClipPlaybackSettingsChanged += () => loopSettingsEvents++;
		controller.ToggleSelectedClipLoop();
		Check(
			report,
			idleClip.Loop == false && loopButton?.IsChecked == false,
			"The loop toggle must update both the selected clip and its toolbar state." );
		Equal(
			report,
			0,
			loopDocumentEvents,
			"Changing loop playback must not rebuild document-driven inspector panels." );
		Equal(
			report,
			1,
			loopSettingsEvents,
			"Changing loop playback must publish one focused transport-state update." );
		Near(
			report,
			500,
			TimelineControlToolbar.CenteredLeft( 1000, 150 ) + 75,
			0.0001f,
			"Timeline transport controls must be centered independently of unequal side content." );
		controller.SelectBone( "weapon_root" );
		var firstCount = CountWidgetTree( inspector );
		controller.SelectControl( "@primary_hand" );
		controller.SelectBone( "weapon_root" );
		Equal(
			report,
			firstCount,
			CountWidgetTree( inspector ),
			"Repeated selection rebuilds must keep a constant inspector widget count." );
		Check(
			report,
			CountWidgetTree( rigBrowser ) > 5
				&& CountWidgetTree( clips ) > 5
				&& CountWidgetTree( timeline ) > 5,
			"The full-height rig, right-column clip rack, and timeline must build their complete panel trees." );
		rigBrowser.Destroy();
		inspector.Destroy();
		clips.Destroy();
		timeline.Destroy();
	}

	private static int CountWidgetTree( Widget widget ) =>
		1 + widget.Children.Sum( CountWidgetTree );

	private static IEnumerable<Widget> WidgetTree( Widget widget )
	{
		yield return widget;
		foreach ( var child in widget.Children )
		{
			foreach ( var descendant in WidgetTree( child ) )
				yield return descendant;
		}
	}

	private static void TestAlignment( WeaponAnimatorSelfTestReport report )
	{
		var grip = new Vector3( 2, 3, 4 );
		var canonical = new Vector3( 12, -3, -2 );
		Check(
			report,
			WeaponAnimationMath.TryCalculateAlignment(
				grip,
				Vector3.Zero,
				Vector3.Forward * 10,
				WeaponUpAxis.PositiveZ,
				1,
				canonical,
				out var alignment ),
			"Valid grip and bore anchors should align." );
		Near( report, canonical, alignment.PhysicalTransform.PointToWorld( grip ), 0.001f, "Grip must land on the canonical origin." );
		Near(
			report,
			Vector3.Forward,
			alignment.PhysicalTransform.Rotation * Vector3.Forward,
			0.001f,
			"Bore must align to viewmodel forward." );

		WeaponAnimationMath.TryCalculateAlignment(
			grip,
			Vector3.Zero,
			Vector3.Backward * 10,
			WeaponUpAxis.PositiveZ,
			1,
			canonical,
			out var reversed );
		Check( report, reversed.BoreMayBeReversed, "Reversed bore points must be detected." );
	}

	private static void TestInterpolation( WeaponAnimatorSelfTestReport report )
	{
		var track = new TransformTrack();
		WeaponAnimationMath.UpsertKey( track, 0, new Transform( Vector3.Zero, Rotation.Identity ) );
		WeaponAnimationMath.UpsertKey( track, 1, new Transform( new Vector3( 10, 0, 0 ), Rotation.FromYaw( 90 ) ) );

		track.Interpolation = TrackInterpolation.Stepped;
		Near( report, 0, WeaponAnimationMath.SampleTrack( track, 0.5f, Transform.Zero ).Position.x, 0.0001f, "Stepped interpolation must hold." );
		track.Interpolation = TrackInterpolation.Linear;
		var halfway = WeaponAnimationMath.SampleTrack( track, 0.5f, Transform.Zero );
		Near( report, 5, halfway.Position.x, 0.0001f, "Linear interpolation must blend position." );
		Near( report, 1, RotationLength( halfway.Rotation ), 0.0001f, "Sampled quaternions must remain normalized." );
		track.Interpolation = TrackInterpolation.Cubic;
		Near( report, 1.56f, WeaponAnimationMath.SampleTrack( track, 0.25f, Transform.Zero ).Position.x, 0.01f, "Cubic interpolation must use smoothstep timing." );
	}

	private static void TestCurveEditorV2( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Curves" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 2;
		clip.SampleRate = 30;
		clip.Tracks.Clear();
		foreach ( var (target, kind) in new[]
		{
			("weapon_root", RigControlKind.Weapon),
			("finger_index_1_R", RigControlKind.Arm),
			("@primary_hand", RigControlKind.Arm),
			("camera", RigControlKind.Camera)
		} )
		{
			var keyed = clip.EnsureTrack( target );
			keyed.Kind = kind;
			WeaponAnimationMath.UpsertKey( keyed, 0, Transform.Zero );
		}
		Equal(
			report,
			4,
			CurveEditingService.KeyedTracks( clip ).Count,
			"Curve track enumeration must include every keyed weapon, arm, target, and camera track." );
		Equal(
			report,
			1,
			CurveEditingService.KeyedTracks( clip, "finger" ).Count,
			"Curve track search must filter without truncating the keyed-track source." );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.SetCurveEditorVisible( true );
		Check(
			report,
			document.Workspace.CurveEditorVisible,
			"The Curves toggle must enter persistent curve-editor mode." );
		controller.SelectCurveTrack( clip, clip.Tracks[^1].Id );
		controller.SetCurveMode( clip, CurveEditorMode.Channels );
		controller.SetCurveChannels(
			clip,
			TransformCurveChannel.PositionX | TransformCurveChannel.RotationY );
		var view = document.Workspace.EnsureCurveView( clip.Id );
		Equal(
			report,
			clip.Tracks[^1].Id,
			view.SelectedTrackId,
			"Selected curve tracks must persist per clip." );
		Check(
			report,
			(view.VisibleChannels & TransformCurveChannel.RotationY) != 0,
			"Multiple visible transform channels must persist together." );

		var motion = new TransformTrack { Interpolation = TrackInterpolation.Cubic };
		var start = WeaponAnimationMath.UpsertKey(
			motion,
			0,
			new Transform( Vector3.Zero, Rotation.FromYaw( 170 ), Vector3.One ) );
		var end = WeaponAnimationMath.UpsertKey(
			motion,
			1,
			new Transform(
				new Vector3( 10, 0, 0 ),
				Rotation.FromYaw( -170 ),
				new Vector3( 1, 3, 1 ) ) );
		CurveEditingService.ApplyPreset(
			motion,
			[],
			CurveEditorMode.Speed,
			TransformCurveChannel.PositionX,
			CurvePreset.EaseIn );
		var speedSpan = motion.FindCurveSpan( start.Id, end.Id )!;
		Near(
			report,
			1,
			WeaponAnimationMath.MotionRateArea( speedSpan.Speed ),
			0.001f,
			"Ease-in speed curves must normalize to a complete one-span traversal." );
		Near(
			report,
			0,
			WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 0 ),
			0.0001f,
			"Ease-in speed must begin at 0×." );
		Near(
			report,
			2,
			WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 1 ),
			0.0001f,
			"Ease-in speed must end at 2×." );
		Near(
			report,
			2.5f,
			WeaponAnimationMath.SampleTrack( motion, 0.5f, Transform.Zero ).Position.x,
			0.02f,
			"Integrated speed must drive monotonic normalized motion progress." );

		speedSpan.Speed = new MotionRateCurve
		{
			StartRate = -2,
			EndRate = -1
		};
		Near(
			report,
			0,
			WeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 0.5f ),
			0.0001f,
			"Motion-rate curves must clamp negative rates at 0×." );
		Near(
			report,
			0.5f,
			WeaponAnimationMath.SampleMotionProgress( speedSpan.Speed, 0.5f ),
			0.0001f,
			"Zero-area speed curves must fall back to linear timing." );

		speedSpan.HasSpeedCurve = false;
		speedSpan.HasInterpolationOverride = true;
		speedSpan.Interpolation = TrackInterpolation.Linear;
		CurveEditingService.ApplyPreset(
			motion,
			[],
			CurveEditorMode.Channels,
			TransformCurveChannel.PositionX
				| TransformCurveChannel.RotationY
				| TransformCurveChannel.ScaleY,
			CurvePreset.EaseInOut );
		var quarter = WeaponAnimationMath.SampleTrack( motion, 0.25f, Transform.Zero );
		Near(
			report,
			1.5625f,
			quarter.Position.x,
			0.01f,
			"Position channel tangents must evaluate as cubic Hermite curves." );
		Near(
			report,
			1.3125f,
			quarter.Scale.y,
			0.01f,
			"Scale channel tangents must evaluate independently." );
		var rotationSample = WeaponAnimationMath.SampleTrack( motion, 0.5f, Transform.Zero );
		Near(
			report,
			1,
			RotationLength( rotationSample.Rotation ),
			0.0001f,
			"Custom Euler rotation channels must normalize their output quaternion." );
		Check(
			report,
			MathF.Abs( MathF.Abs( rotationSample.Rotation.Angles().yaw ) - 180 ) < 1,
			"Rotation channels must unwrap through the shortest angular path." );
		Check(
			report,
			(start.CurveTangents.FreeHandles & TransformCurveChannel.PositionX) == 0,
			"Curve handles must be aligned by default." );
		CurveEditingService.SetTangent(
			start,
			TransformCurveChannel.PositionX,
			false,
			4,
			true );
		Check(
			report,
			(start.CurveTangents.FreeHandles & TransformCurveChannel.PositionX) != 0,
			"Alt-style tangent edits must be able to break one handle side." );
		CurveEditingService.AlignHandles(
			start,
			TransformCurveChannel.PositionX );
		Near(
			report,
			CurveEditingService.GetTangent(
				start,
				TransformCurveChannel.PositionX,
				true ),
			CurveEditingService.GetTangent(
				start,
				TransformCurveChannel.PositionX,
				false ),
			0.0001f,
			"Handle alignment must restore matching facing tangents." );

		var topology = new TransformTrack();
		var first = WeaponAnimationMath.UpsertKey(
			topology, 0, new Transform( Vector3.Zero ) );
		var middle = WeaponAnimationMath.UpsertKey(
			topology, 1, new Transform( Vector3.One ) );
		var last = WeaponAnimationMath.UpsertKey(
			topology, 2, new Transform( Vector3.One * 2 ) );
		topology.EnsureCurveSpan( first.Id, middle.Id ).HasSpeedCurve = true;
		topology.EnsureCurveSpan( middle.Id, last.Id ).HasSpeedCurve = true;
		CurveEditingService.RemoveKeysAndRepair( topology, x => x.Id == middle.Id );
		var repaired = topology.FindCurveSpan( first.Id, last.Id );
		Check(
			report,
			repaired?.HasInterpolationOverride == true
				&& repaired.Interpolation == TrackInterpolation.Linear,
			"Deleting a curve endpoint must create a safe linear bridge between new neighbors." );

		var legacy = WeaponAnimationDocument.CreateDefault( "Schema 3 curves" );
		legacy.SchemaVersion = 3;
		var legacyClip = legacy.EnsureClip( WeaponClipRole.Fire );
		var legacyTrack = legacyClip.EnsureTrack( "legacy" );
		legacyTrack.Interpolation = TrackInterpolation.Cubic;
		WeaponAnimationMath.UpsertKey(
			legacyTrack, 0, new Transform( Vector3.Zero ) );
		WeaponAnimationMath.UpsertKey(
			legacyTrack, 1, new Transform( new Vector3( 10, 0, 0 ) ) );
		var before = WeaponAnimationMath.SampleTrack(
			legacyTrack, 0.25f, Transform.Zero );
		var migration = WeaponAnimationMigration.MigrateAndRepair( legacy );
		var after = WeaponAnimationMath.SampleTrack(
			legacyTrack, 0.25f, Transform.Zero );
		Check(
			report,
			migration.CurveSchemaMigrated
				&& legacy.SchemaVersion == WeaponAnimationDocument.CurrentSchemaVersion,
			"Schema-v3 documents must migrate to schema v4." );
		Near(
			report,
			before.Position,
			after.Position,
			0.0001f,
			"Schema-v3 migration must preserve exact legacy playback." );
		Check(
			report,
			legacyTrack.CurveSpans.Count == 0,
			"Migration must not materialize custom curve spans until edited." );

		var lifecycleDocument = WeaponAnimationDocument.CreateDefault( "Curve lifecycle" );
		var lifecycleClip = lifecycleDocument.GetSelectedClip()!;
		lifecycleClip.Duration = 2;
		lifecycleClip.SampleRate = 30;
		lifecycleClip.Tracks.Clear();
		var lifecycleTrack = lifecycleClip.EnsureTrack( "slide" );
		var lifecycleStart = WeaponAnimationMath.UpsertKey(
			lifecycleTrack, 0, new Transform( Vector3.Zero ) );
		var lifecycleEnd = WeaponAnimationMath.UpsertKey(
			lifecycleTrack, 1, new Transform( new Vector3( 4, 0, 0 ) ) );
		CurveEditingService.ApplyPreset(
			lifecycleTrack,
			[],
			CurveEditorMode.Speed,
			TransformCurveChannel.PositionX,
			CurvePreset.EaseOut );
		var lifecycleSpanId = lifecycleTrack.CurveSpans.Single().Id;
		var lifecycleController = new WeaponAnimatorController();
		lifecycleController.SetDocument( lifecycleDocument );
		lifecycleController.SetSelectedKeys(
			[lifecycleStart.Id, lifecycleEnd.Id] );
		var starts = lifecycleTrack.Keys.ToDictionary( x => x.Id, x => x.Time );
		lifecycleController.BeginSelectedKeyMove();
		lifecycleController.UpdateSelectedKeyMove( starts, 5 );
		lifecycleController.EndSelectedKeyMove( starts, 5 );
		lifecycleTrack = lifecycleController.Document.GetSelectedClip()!
			.Tracks.Single( x => x.Target == "slide" );
		Check(
			report,
			lifecycleTrack.CurveSpans.Any( x => x.Id == lifecycleSpanId ),
			"Moving curve endpoints must retain their stable span data." );

		lifecycleController.CopySelectedKeys();
		lifecycleController.SetTimelineFrame( 5 );
		lifecycleController.PasteKeys();
		lifecycleTrack = lifecycleController.Document.GetSelectedClip()!
			.Tracks.Single( x => x.Target == "slide" );
		Check(
			report,
			lifecycleTrack.CurveSpans.Any( x =>
				x.HasSpeedCurve
					&& lifecycleController.SelectedKeys.Contains( x.StartKeyId )
					&& lifecycleController.SelectedKeys.Contains( x.EndKeyId ) ),
			"Copy and paste must preserve a span curve only when both endpoint keys are copied." );

		var invalidDocument = ValidDocument();
		var invalidClip = invalidDocument.EnsureClip( WeaponClipRole.Fire );
		var invalidTrack = invalidClip.EnsureTrack( "weapon_root" );
		var invalidStart = WeaponAnimationMath.UpsertKey(
			invalidTrack, 0, new Transform( Vector3.Zero ) );
		var invalidEnd = WeaponAnimationMath.UpsertKey(
			invalidTrack, 1, new Transform( Vector3.One ) );
		var invalidSpan = invalidTrack.EnsureCurveSpan(
			invalidStart.Id, invalidEnd.Id );
		invalidSpan.HasSpeedCurve = true;
		invalidSpan.Speed = new MotionRateCurve
		{
			StartRate = -1,
			EndRate = -1
		};
		Check(
			report,
			WeaponAnimationValidator.ValidateForGeneration( invalidDocument )
				.Issues.Any( x => x.Code == "curve.speed_invalid" ),
			"Zero-area speed curves must produce an explicit validation warning." );

		var exportDocument = WeaponAnimationDocument.CreateDefault( "Curve export" );
		var exportClip = exportDocument.GetSelectedClip()!;
		exportClip.Duration = 1;
		exportClip.SampleRate = 30;
		exportClip.IsBindPoseSeed = false;
		exportClip.Tracks.Clear();
		var exportTrack = exportClip.EnsureTrack( "root" );
		WeaponAnimationMath.UpsertKey(
			exportTrack, 0, new Transform( Vector3.Zero ) );
		WeaponAnimationMath.UpsertKey(
			exportTrack, 1, new Transform( new Vector3( 8, 0, 0 ) ) );
		CurveEditingService.ApplyPreset(
			exportTrack,
			[],
			CurveEditorMode.Speed,
			TransformCurveChannel.PositionX,
			CurvePreset.EaseInOut );
		var exportSkeleton = new HostSkeleton();
		exportSkeleton.Add( new HostBone
		{
			Name = "root",
			BindModelTransform = Transform.Zero
		} );
		var firstExport = DmxWriter.WriteAnimation(
			exportDocument, exportSkeleton, exportClip );
		var secondExport = DmxWriter.WriteAnimation(
			exportDocument, exportSkeleton, exportClip );
		Equal(
			report,
			firstExport,
			secondExport,
			"Customized curves must produce deterministic sampled animation output." );
	}

	private static void TestFrameSnapping( WeaponAnimatorSelfTestReport report )
	{
		Near( report, 10.0f / 30.0f, WeaponAnimationMath.SnapTime( 0.34f, 30, false ), 0.0001f, "Frame snapping must select the nearest frame." );
		Near( report, 0.34f, WeaponAnimationMath.SnapTime( 0.34f, 30, true ), 0.0001f, "Subframe keys must preserve time." );
	}

	private static void TestTimelineNavigation( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Timeline navigation" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 10;
		clip.SampleRate = 30;
		var full = TimelineInteraction.ResolveRange( clip, null );
		Equal( report, 0, full.StartFrame, "A new timeline view must begin at frame zero." );
		Equal( report, 300, full.EndFrame, "A new timeline view must cover the complete clip." );

		var zoomed = TimelineInteraction.Zoom( new TimelineFrameRange( 60, 240 ), 300, true );
		Equal( report, 144, zoomed.Span, "Ctrl+wheel zoom must reduce the visible frame span." );
		Equal(
			report,
			300,
			zoomed.StartFrame + zoomed.EndFrame,
			"Ctrl+wheel zoom must preserve the range midpoint." );
		var panned = TimelineInteraction.Pan( zoomed, 500, 300 );
		Equal( report, 300, panned.EndFrame, "Range panning must clamp at the clip end." );
		var minimum = TimelineInteraction.ResizeStart(
			new TimelineFrameRange( 0, 10 ),
			10,
			300 );
		Equal(
			report,
			TimelineInteraction.MinimumVisibleFrameIntervals,
			minimum.Span,
			"Range handles must retain the minimum two-frame interval." );

		var closeTicks = TimelineInteraction.TickSpacing( 10 );
		var wideTicks = TimelineInteraction.TickSpacing( 0.5f );
		Equal( report, 1, closeTicks.MinorFrames, "Zoomed timelines must expose individual frame ticks." );
		Check(
			report,
			wideTicks.MinorFrames > closeTicks.MinorFrames
				&& wideTicks.MajorFrames > closeTicks.MajorFrames,
			"Tick spacing must become coarser as the visible frame density increases." );
		var marker = TimelineInteraction.KeyMarkerPosition(
			337.42f, 44, 100, 500, TimelineEditorCanvas.TrackHeight );
		Near(
			report,
			337,
			marker.X,
			0.0001f,
			"Key markers must snap horizontally to whole pixels." );
		Near(
			report,
			55,
			marker.Y,
			0.0001f,
			"Key markers must remain vertically centered on their row." );
		Near(
			report,
			105,
			TimelineInteraction.KeyMarkerPosition(
				100, 0, 100, 500, TimelineEditorCanvas.TrackHeight ).X,
			0.0001f,
			"First-frame diamonds must remain fully inside the graph." );
		Near(
			report,
			495,
			TimelineInteraction.KeyMarkerPosition(
				500, 0, 100, 500, TimelineEditorCanvas.TrackHeight ).X,
			0.0001f,
			"Last-frame diamonds must not be covered by the scrollbar gutter." );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.SetTimelineRange( clip, new TimelineFrameRange( 30, 90 ) );
		controller.SetTimelineVerticalScroll( clip, 132 );
		var state = document.Workspace.GetTimelineView( clip.Id );
		Check(
			report,
			state is not null,
			"Changing a timeline view must create its per-clip workspace state." );
		Near( report, 1, state!.VisibleStart, 0.0001f, "Timeline range start must persist in seconds." );
		Near( report, 3, state.VisibleEnd, 0.0001f, "Timeline range end must persist in seconds." );
		Near( report, 132, state.VerticalScroll, 0.0001f, "Vertical track scroll must persist per clip." );

		clip.Tracks.Add( new TransformTrack { Target = "one" } );
		clip.Tracks.Add( new TransformTrack { Target = "two" } );
		document.Rig.VisibilityParts.Add( new WeaponVisibilityPart() );
		Equal(
			report,
			4,
			TimelineInteraction.TrackRowCount( document, clip ),
			"Timeline row count must include every transform track, visibility track, and the tag row." );
	}

	private static void TestTimelineSelectionAndMovement( WeaponAnimatorSelfTestReport report )
	{
		var first = Guid.NewGuid();
		var second = Guid.NewGuid();
		var third = Guid.NewGuid();
		var replaced = TimelineInteraction.CombineKeySelection(
			[first],
			[second, third],
			additive: false,
			toggle: false );
		Check(
			report,
			replaced.SetEquals( [second, third] ),
			"A plain marquee must replace the previous key selection." );
		var added = TimelineInteraction.CombineKeySelection(
			[first],
			[second],
			additive: true,
			toggle: false );
		Check(
			report,
			added.SetEquals( [first, second] ),
			"Shift-marquee must add intersected keys." );
		var toggled = TimelineInteraction.CombineKeySelection(
			[first, second],
			[second, third],
			additive: false,
			toggle: true );
		Check(
			report,
			toggled.SetEquals( [first, third] ),
			"Ctrl-marquee must toggle every intersected key." );
		var scrolledMarquee = TimelineInteraction.ProjectMarquee(
			startX: 220,
			startContentY: 400,
			currentX: 520,
			currentContentY: 290,
			verticalScroll: 40,
			minimumX: 180,
			maximumX: 500 );
		Near(
			report,
			360,
			scrolledMarquee.Bottom,
			0.0001f,
			"A marquee start must remain anchored to its original track while scrolling." );
		Near(
			report,
			250,
			scrolledMarquee.Top,
			0.0001f,
			"A scrolling marquee endpoint must follow the newly revealed content." );
		Near(
			report,
			500,
			scrolledMarquee.Right,
			0.0001f,
			"A marquee must remain clipped to the graph's right edge." );
		Equal(
			report,
			-2,
			TimelineInteraction.ClampGroupFrameDelta( [2, 5], -20, 30 ),
			"Moving keys before frame zero must clamp the group as a unit." );
		Equal(
			report,
			25,
			TimelineInteraction.ClampGroupFrameDelta( [2, 5], 40, 30 ),
			"Moving keys past the clip end must preserve their internal spacing." );

		var document = WeaponAnimationDocument.CreateDefault( "Timeline key move" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 1;
		clip.SampleRate = 30;
		var track = clip.EnsureTrack( "weapon_root" );
		var keyA = WeaponAnimationMath.UpsertKey( track, 2f / 30, Transform.Zero );
		var keyB = WeaponAnimationMath.UpsertKey( track, 5f / 30, Transform.Zero );
		WeaponAnimationMath.UpsertKey( track, 7f / 30, Transform.Zero );
		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.SetSelectedKeys( [keyA.Id, keyB.Id] );
		var starts = new Dictionary<Guid, float>
		{
			[keyA.Id] = keyA.Time,
			[keyB.Id] = keyB.Time
		};
		controller.BeginSelectedKeyMove();
		controller.UpdateSelectedKeyMove( starts, 2 );
		controller.EndSelectedKeyMove( starts, 2 );
		Equal(
			report,
			2,
			track.Keys.Count,
			"A moved key must replace an unselected key occupying its destination frame." );
		Check(
			report,
			track.Keys.Select( x => TimelineInteraction.TimeToFrame( x.Time, 30 ) )
				.SequenceEqual( [4, 7] ),
			"Selected keys must move by the same snapped frame delta." );
		controller.Undo();
		clip = controller.Document.GetSelectedClip()!;
		Equal(
			report,
			3,
			clip.EnsureTrack( "weapon_root" ).Keys.Count,
			"A complete key drag must undo as one action." );

		var deleteDocument = WeaponAnimationDocument.CreateDefault( "Timeline key delete" );
		var deleteClip = deleteDocument.GetSelectedClip()!;
		var deleteTransformKey = WeaponAnimationMath.UpsertKey(
			deleteClip.EnsureTrack( "weapon_root" ),
			0,
			Transform.Zero );
		var visibilityPart = new WeaponVisibilityPart { Name = "Magazine" };
		deleteDocument.Rig.VisibilityParts.Add( visibilityPart );
		var deleteVisibilityKey = new VisibilityKey { Time = 0, Visible = false };
		deleteClip.EnsureVisibilityTrack( visibilityPart.Id ).Keys.Add( deleteVisibilityKey );
		var deleteController = new WeaponAnimatorController();
		deleteController.SetDocument( deleteDocument );
		deleteController.SetSelectedKeys( [deleteTransformKey.Id, deleteVisibilityKey.Id] );
		deleteController.DeleteSelectedKeys();
		deleteClip = deleteController.Document.GetSelectedClip()!;
		Equal(
			report,
			0,
			deleteClip.Tracks.SelectMany( x => x.Keys ).Count(),
			"Deleting selected keys must remove transform keys." );
		Equal(
			report,
			0,
			deleteClip.VisibilityTracks.SelectMany( x => x.Keys ).Count(),
			"Deleting selected keys must remove visibility keys." );
		Equal(
			report,
			0,
			deleteController.SelectedKeys.Count,
			"Deleting keys must clear the stale key selection." );
		deleteController.Undo();
		deleteClip = deleteController.Document.GetSelectedClip()!;
		Equal(
			report,
			2,
			deleteClip.Tracks.SelectMany( x => x.Keys ).Count()
				+ deleteClip.VisibilityTracks.SelectMany( x => x.Keys ).Count(),
			"Deleting a mixed key selection must undo as one action." );
	}

	private static void TestTimelineKeyReversal( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Timeline reverse" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 1;
		clip.SampleRate = 30;
		var track = clip.EnsureTrack( "weapon_root" );
		track.Interpolation = TrackInterpolation.Linear;
		var start = WeaponAnimationMath.UpsertKey(
			track,
			0,
			new Transform( new Vector3( 0, 0, 0 ), Rotation.Identity, Vector3.One ) );
		var end = WeaponAnimationMath.UpsertKey(
			track,
			1,
			new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, Vector3.One ) );
		start.CurveTangents.PositionOut = new Vector3( 4, 0, 0 );
		end.CurveTangents.PositionIn = new Vector3( 12, 0, 0 );
		var span = track.EnsureCurveSpan( start.Id, end.Id );
		span.CustomChannels = TransformCurveChannel.PositionX;
		span.HasSpeedCurve = true;
		span.Speed = new MotionRateCurve
		{
			StartRate = 0.4f,
			EndRate = 1.6f,
			StartSlope = 0.5f,
			EndSlope = -0.25f,
			StartHandleMode = CurveHandleMode.Free,
			EndHandleMode = CurveHandleMode.Aligned
		};
		var sampleTimes = new[] { 0.0f, 0.2f, 0.5f, 0.8f, 1.0f };
		var sourceSamples = sampleTimes
			.Select( x => WeaponAnimationMath.SampleTrack( track, x, Transform.Zero ).Position )
			.ToArray();

		var visibilityPart = new WeaponVisibilityPart { Name = "Magazine" };
		document.Rig.VisibilityParts.Add( visibilityPart );
		var visibility = clip.EnsureVisibilityTrack( visibilityPart.Id );
		var hidden = new VisibilityKey { Time = 0, Visible = false };
		var shown = new VisibilityKey { Time = 1, Visible = true };
		visibility.Keys.AddRange( [hidden, shown] );

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.ReverseKeys();
		clip = controller.Document.GetSelectedClip()!;
		track = clip.EnsureTrack( "weapon_root" );
		Equal(
			report,
			end.Id,
			track.Keys[0].Id,
			"With no key selection, Reverse must flip all transform keys across the clip." );
		Equal(
			report,
			start.Id,
			track.Keys[^1].Id,
			"Whole-clip reversal must place the first key at the last frame." );
		var reversedSpan = track.FindCurveSpan( end.Id, start.Id );
		Check(
			report,
			reversedSpan is not null,
			"Custom curve spans must reverse with their endpoint keys." );
		if ( reversedSpan is not null )
		{
			Near(
				report,
				span.Speed.EndRate,
				reversedSpan.Speed.StartRate,
				0.0001f,
				"Reversing a speed curve must swap its endpoint rates." );
			Near(
				report,
				-span.Speed.EndSlope,
				reversedSpan.Speed.StartSlope,
				0.0001f,
				"Reversing a speed curve must invert its former end slope." );
			Equal(
				report,
				span.Speed.EndHandleMode,
				reversedSpan.Speed.StartHandleMode,
				"Reversing a speed curve must swap its handle modes." );
		}
		Near(
			report,
			-12,
			track.Keys[0].CurveTangents.PositionOut.x,
			0.0001f,
			"Reversed channel curves must negate the former incoming tangent." );
		Near(
			report,
			-4,
			track.Keys[^1].CurveTangents.PositionIn.x,
			0.0001f,
			"Reversed channel curves must negate the former outgoing tangent." );
		for ( var i = 0; i < sampleTimes.Length; i++ )
		{
			Near(
				report,
				sourceSamples[^(i + 1)],
				WeaponAnimationMath.SampleTrack(
					track,
					sampleTimes[i],
					Transform.Zero ).Position,
				0.01f,
				"Reversed custom curves must reproduce the original motion backward." );
		}
		visibility = clip.EnsureVisibilityTrack( visibilityPart.Id );
		Equal(
			report,
			shown.Id,
			visibility.Keys[0].Id,
			"Whole-clip reversal must also flip visibility keys." );

		controller.Undo();
		clip = controller.Document.GetSelectedClip()!;
		track = clip.EnsureTrack( "weapon_root" );
		Equal(
			report,
			start.Id,
			track.Keys[0].Id,
			"Transform, curve, and visibility reversal must undo as one action." );

		var selectionDocument = WeaponAnimationDocument.CreateDefault( "Selected reverse" );
		var selectionClip = selectionDocument.GetSelectedClip()!;
		selectionClip.Duration = 2;
		selectionClip.SampleRate = 10;
		var selectionTrack = selectionClip.EnsureTrack( "slide" );
		var first = WeaponAnimationMath.UpsertKey(
			selectionTrack,
			0.2f,
			new Transform( new Vector3( 2, 0, 0 ), Rotation.Identity, Vector3.One ) );
		var middle = WeaponAnimationMath.UpsertKey(
			selectionTrack,
			0.6f,
			new Transform( new Vector3( 6, 0, 0 ), Rotation.Identity, Vector3.One ) );
		var last = WeaponAnimationMath.UpsertKey(
			selectionTrack,
			1.0f,
			new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, Vector3.One ) );
		var selectionController = new WeaponAnimatorController();
		selectionController.SetDocument( selectionDocument );
		selectionController.SetSelectedKeys( [first.Id, last.Id] );
		selectionController.ReverseKeys();
		selectionTrack = selectionController.Document.GetSelectedClip()!.EnsureTrack( "slide" );
		Equal(
			report,
			last.Id,
			selectionTrack.Keys[0].Id,
			"Selected reversal must flip keys around the selected range, not the clip bounds." );
		Equal(
			report,
			middle.Id,
			selectionTrack.Keys[1].Id,
			"Keys outside the reversed selection must retain their frame." );
		Equal(
			report,
			first.Id,
			selectionTrack.Keys[2].Id,
			"Selected reversal must preserve key identities and selection." );
		Check(
			report,
			selectionController.SelectedKeys.ToHashSet().SetEquals( [first.Id, last.Id] ),
			"Reversed keys must remain selected for immediate follow-up editing." );
	}

	private static void TestTimelinePlayback( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault( "Timeline playback" );
		var clip = document.GetSelectedClip()!;
		clip.Duration = 1;
		clip.SampleRate = 30;
		clip.Loop = false;
		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );

		controller.SetTimelineTime( 0.01f );
		Near( report, 0, document.Workspace.TimelineTime, 0.0001f, "Timeline seeking must reject fractional-frame positions." );
		controller.SetTimelineTime( 0.02f );
		Near( report, 1f / 30, document.Workspace.TimelineTime, 0.0001f, "Timeline seeking must snap to the nearest whole frame." );
		controller.JumpToLastFrame();
		controller.TogglePlayback();
		Check( report, controller.IsPlaying, "Play must enter the shared playback state." );
		Near( report, 0, document.Workspace.TimelineTime, 0.0001f, "Playing from the last frame must restart at frame zero." );
		controller.AdvancePlayback( 0.04f );
		Equal(
			report,
			1,
			TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
			"Playback must advance through whole-frame preview positions." );
		var movingTrack = clip.EnsureTrack( "weapon_root" );
		var movingKey = WeaponAnimationMath.UpsertKey(
			movingTrack,
			0.2f,
			new Transform( new Vector3( 2, 0, 0 ), Rotation.Identity, Vector3.One ) );
		controller.SetSelectedKeys( [movingKey.Id] );
		controller.BeginSelectedKeyMove();
		controller.UpdateSelectedKeyMove(
			new Dictionary<Guid, float> { [movingKey.Id] = movingKey.Time },
			1 );
		Check(
			report,
			controller.IsPlaying,
			"Selecting and dragging a key must not pause viewport playback." );
		controller.EndSelectedKeyMove(
			new Dictionary<Guid, float> { [movingKey.Id] = 0.2f },
			1 );
		Check(
			report,
			controller.IsPlaying,
			"Committing a key drag must leave playback running for live SampleTrack checks." );
		controller.StepTimelineFrame( 1 );
		Check( report, !controller.IsPlaying, "Manual frame stepping must pause playback." );
		Equal(
			report,
			2,
			TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
			"Next-frame controls must advance exactly one frame." );
		controller.JumpToLastFrame();
		controller.StepTimelineFrame( 1 );
		Equal(
			report,
			30,
			TimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),
			"Frame stepping must clamp at the final frame." );
		controller.ToggleSelectedClipLoop();
		Check(
			report,
			clip.Loop,
			"The selected clip loop state must be editable through the shared controller." );
		controller.TogglePlayback();
		controller.AdvancePlayback( 1.1f );
		Check(
			report,
			controller.IsPlaying
				&& TimelineInteraction.TimeToFrame(
					document.Workspace.TimelineTime,
					clip.SampleRate ) == 3,
			"Looped playback must wrap and remain active." );
		controller.Undo();
		Check(
			report,
			!controller.Document.GetSelectedClip()!.Loop,
			"Changing the loop state must be one undoable action." );
	}

	private static void TestTwoBoneIk( WeaponAnimatorSelfTestReport report )
	{
		var reachable = WeaponAnimationMath.SolveTwoBone(
			Vector3.Zero,
			Vector3.Forward,
			Vector3.Forward * 2,
			new Vector3( 1.5f, 0.4f, 0 ),
			Vector3.Up );
		Check( report, reachable.Reachable, "An in-range hand target must be reachable." );
		Near( report, new Vector3( 1.5f, 0.4f, 0 ), reachable.End, 0.001f, "Reachable target must be solved exactly." );

		var clamped = WeaponAnimationMath.SolveTwoBone(
			Vector3.Zero,
			Vector3.Forward,
			Vector3.Forward * 2,
			Vector3.Forward * 10,
			Vector3.Up );
		Check( report, !clamped.Reachable, "An overextended target must be reported." );
		Check( report, clamped.SolvedDistance < 2, "Overextension must clamp below total arm length." );
	}

	private static void TestConstraintDrivenIk( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		document.Binding.PrimaryHand.IsBound = true;
		document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.5f, 0, 0 ) );
		document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );

		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
		skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
		skeleton.Add( Bone( "bolt", "root", new Vector3( 1.2f, 0.8f, 0 ) ) );

		var clip = document.EnsureClip( WeaponClipRole.Idle );
		clip.Constraints.Add( new TimedConstraint
		{
			SourceControl = "@primary_hand",
			TargetBone = "bolt",
			StartTime = 0,
			EndTime = 1,
			MaintainOffset = false
		} );
		var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 0.5f );
		Near( report, new Vector3( 1.2f, 0.8f, 0 ), pose.Model["hand_R"].Position, 0.002f, "Constraint must drive the IK target before the arm solve." );
	}

	private static void TestIkDescendantPropagation( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		document.Binding.PrimaryHand.IsBound = true;
		document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.2f, 1.2f, 0 ) );
		document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );

		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
		skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
		skeleton.Add( Bone( "finger_R", "hand_R", new Vector3( 2.5f, 0.2f, 0 ) ) );
		skeleton.Add( Bone( "forearm_twist_R", "arm_lower_R", new Vector3( 1.5f, 0, 0 ) ) );

		var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );
		var fingerLocal = skeleton.GetBindLocal( skeleton.ByName["finger_R"] );
		var twistLocal = skeleton.GetBindLocal( skeleton.ByName["forearm_twist_R"] );
		Near(
			report,
			pose.Model["hand_R"].PointToWorld( fingerLocal.Position ),
			pose.Model["finger_R"].Position,
			0.001f,
			"Finger descendants must follow the solved hand." );
		Near(
			report,
			pose.Model["arm_lower_R"].PointToWorld( twistLocal.Position ),
			pose.Model["forearm_twist_R"].Position,
			0.001f,
			"Twist descendants must follow the solved forearm." );
	}

	private static void TestConstraintMaintainedOffset( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		document.Binding.PrimaryHand.IsBound = true;
		document.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.5f, 0, 0 ) );
		document.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );

		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_upper_R", "root", Vector3.Zero ) );
		skeleton.Add( Bone( "arm_lower_R", "arm_upper_R", new Vector3( 1, 0, 0 ) ) );
		skeleton.Add( Bone( "hand_R", "arm_lower_R", new Vector3( 2, 0, 0 ) ) );
		skeleton.Add( Bone( "bolt", "root", new Vector3( 1, 0, 0 ) ) );

		var clip = document.EnsureClip( WeaponClipRole.Idle );
		var boltTrack = clip.EnsureTrack( "bolt" );
		WeaponAnimationMath.UpsertKey( boltTrack, 0, new Transform( new Vector3( 1, 0, 0 ) ) );
		WeaponAnimationMath.UpsertKey( boltTrack, 1, new Transform( new Vector3( 1.2f, 0, 0 ) ) );
		clip.Constraints.Add( new TimedConstraint
		{
			SourceControl = "@primary_hand",
			TargetBone = "bolt",
			StartTime = 0,
			EndTime = 1,
			MaintainOffset = true
		} );

		var pose = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 1 );
		Near( report, new Vector3( 1.7f, 0, 0 ), pose.Model["hand_R"].Position, 0.002f, "Maintain-offset constraints must preserve the start-frame hand offset." );
	}

	private static void TestHostSkeletonCache( WeaponAnimatorSelfTestReport report )
	{
		HostSkeletonBuilder.ClearCache();
		var document = ValidDocument();
		var first = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		var second = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			ReferenceEquals( first, second ),
			"An unchanged document must reuse the cached host skeleton." );

		// Calibration nudges can be far below display precision, so the signature must compare
		// exact float bits rather than a rounded or formatted value.
		document.Calibration.PhysicalTransform =
			document.Calibration.PhysicalTransform.WithPosition(
				new Vector3( 0.0000001f, 0, 0 ) );
		var afterTinyMove = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			!ReferenceEquals( first, afterTinyMove ),
			"A sub-precision calibration change must still invalidate the cached skeleton." );

		document.Rig.Bones[0].BindModelTransform =
			document.Rig.Bones[0].BindModelTransform.WithScale( 1.0000001f );
		var afterBoneChange = HostSkeletonBuilder.BuildCached(
			document,
			includeArmProfile: false );
		Check(
			report,
			!ReferenceEquals( afterTinyMove, afterBoneChange ),
			"A bone bind change must invalidate the cached skeleton." );

		document.Binding.PrimaryHand.Transform =
			document.Binding.PrimaryHand.Transform.WithPosition( new Vector3( 3, 2, 1 ) );
		var afterBindingChange = HostSkeletonBuilder.BuildCached(
			document,
			includeArmProfile: false );
		Check(
			report,
			!ReferenceEquals( afterBoneChange, afterBindingChange ),
			"A hand binding change must invalidate the cached skeleton." );

		var reread = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );
		Check(
			report,
			ReferenceEquals( afterBindingChange, reread ),
			"Rebuilding after a change must repopulate the cache rather than rebuild every call." );
		HostSkeletonBuilder.ClearCache();
	}

	private static void TestControllerHistoryAndClipboard( WeaponAnimatorSelfTestReport report )
	{
		var controller = new WeaponAnimatorController();
		controller.SetDocument( WeaponAnimationDocument.CreateDefault( "History" ) );
		controller.Mutate( "Rename", document => document.Name = "Changed" );
		Check( report, controller.IsDirty && controller.CanUndo, "A mutation must mark the document dirty and create undo history." );
		controller.Undo();
		Equal( report, "History", controller.Document.Name, "Undo must restore the previous snapshot." );
		controller.Redo();
		Equal( report, "Changed", controller.Document.Name, "Redo must restore the changed snapshot." );
		var documentEvents = 0;
		var poseEvents = 0;
		var selectionEvents = 0;
		var keySelectionEvents = 0;
		controller.DocumentChanged += () => documentEvents++;
		controller.PoseChanged += () => poseEvents++;
		controller.SelectionChanged += () => selectionEvents++;
		controller.KeySelectionChanged += () => keySelectionEvents++;
		controller.BeginContinuousEdit( "Scrub name" );
		controller.UpdateContinuousEdit( document => document.Name = "Scrub A" );
		controller.UpdateContinuousEdit( document => document.Name = "Scrub B" );
		Equal(
			report,
			0,
			documentEvents,
			"A live scrub must not broadcast full document rebuilds while dragging." );
		Equal(
			report,
			2,
			poseEvents,
			"A live scrub must publish lightweight pose previews." );
		controller.EndContinuousEdit();
		Equal(
			report,
			1,
			documentEvents,
			"Completing a scrub must publish one consolidated document change." );
		controller.Undo();
		Equal( report, "Changed", controller.Document.Name, "A continuous drag must collapse into one undo step." );
		controller.Redo();
		Equal( report, "Scrub B", controller.Document.Name, "Redo must restore the final continuous-drag value." );

		var clip = controller.Document.GetSelectedClip()!;
		var track = clip.EnsureTrack( "weapon_root" );
		var key = WeaponAnimationMath.UpsertKey( track, 0, new Transform( new Vector3( 1, 2, 3 ) ) );
		var selectionBeforeKeys = selectionEvents;
		controller.SelectKeys( [key.Id], false );
		Equal(
			report,
			selectionBeforeKeys,
			selectionEvents,
			"Key selection must not broadcast a control-selection rebuild." );
		Check(
			report,
			keySelectionEvents > 0,
			"Key selection must publish its dedicated lightweight event." );
		controller.CopySelectedKeys();
		controller.SetTimelineTime( 0.5f );
		controller.PasteKeys();
		clip = controller.Document.GetSelectedClip()!;
		Equal( report, 2, clip.EnsureTrack( "weapon_root" ).Keys.Count, "Pasting keys must duplicate the clipboard payload." );
		Near(
			report,
			0.5f,
			clip.EnsureTrack( "weapon_root" ).Keys.Max( x => x.Time ),
			0.0001f,
			"Pasted keys must be offset to the playhead." );

		var keyController = new WeaponAnimatorController();
		var keyDocument = ValidDocument();
		keyController.SetDocument( keyDocument );
		keyController.SelectBone( "weapon_root" );
		keyController.SetTimelineTime( 0.5f );
		keyController.KeySelectedTransform();
		Check(
			report,
			keyController.Document.GetSelectedClip()!.Tracks
				.Single( current => current.Target == "weapon_root" )
				.Keys.Any( current => MathF.Abs( current.Time - 0.5f ) < 0.0001f ),
			"The shared K/Add Key command must key a selected weapon bone." );
	}

	private static void TestValidation( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		Check( report, WeaponAnimationValidator.ValidateCalibration( document ).IsValid, "A complete calibration should pass." );
		Check( report, WeaponAnimationValidator.ValidateForGeneration( document ).IsValid, "Idle-only generation should pass with action warnings." );
		Check(
			report,
			WeaponAnimationValidator.ValidateForGeneration( document ).Issues.Any( x =>
				x.Severity == ValidationSeverity.Warning && x.Code == "clip.fallback" ),
			"Missing action clips must remain warnings." );
		document.Source.SourcePath = "weapons/test/source.smd";
		var smdValidation = WeaponAnimationValidator.ValidateForGeneration( document );
		Check(
			report,
			smdValidation.Issues.Any( issue =>
				issue.Blocking && issue.Code == "source.not_embeddable" ),
			"SMD projects must explain the ModelDoc generation limitation before Generate runs." );
		Check(
			report,
			smdValidation.Issues.Any( issue =>
				issue.Code == "source.not_embeddable"
				&& issue.Message.Contains( "SMD", StringComparison.Ordinal ) ),
			"The generation-format diagnostic must name the unsupported source extension." );
		document.Source.SourcePath = "weapons/test/source.vmdl";
		Check(
			report,
			WeaponAnimationValidator.ValidateForGeneration( document ).Issues.All( issue =>
				issue.Code != "source.not_embeddable" ),
			"VMDL projects must pass source-format validation through the generated adapter path." );
		document.Source.SourcePath = "weapons/test/source.fbx";
		document.Calibration.Anchors.RemoveAll( anchor =>
			anchor.Kind is AnchorKind.RearBore or AnchorKind.FrontBore );
		Check(
			report,
			WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
			"Auto-align markers must not block an already-oriented weapon." );

		document.Source.OriginalModelDimensions = Vector3.One;
		document.Calibration.PhysicalTransform =
			document.Calibration.PhysicalTransform.WithScale( 1 );
		Check(
			report,
			WeaponAnimationValidator.ValidateCalibration( document ).Issues.Any( issue =>
				issue.Code == "scale.implausible" ),
			"Implausible-scale validation must use persisted source bounds without requiring a measurement." );

		document.Rig.Bones.Add( new WeaponBoneDefinition { Name = "hand_R" } );
			Check(
				report,
				!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
				"Facepunch-reserved weapon bone names must block calibration." );

			document.Rig.Bones.RemoveAt( document.Rig.Bones.Count - 1 );
			document.Rig.Bones[0].Name = "root";
			document.Rig.Bones[0].Classification = WeaponBoneClassification.WeaponRoot;
			document.Rig.RootBone = "root";
			Check(
				report,
				WeaponAnimationValidator.ValidateCalibration( document ).IsValid,
				"A classified source root may use a reserved name before wrapper normalization." );
	}

	private static void TestGenerationOutputPaths( WeaponAnimatorSelfTestReport report )
	{
		var contentRoot = Path.Combine(
			Path.GetTempPath(),
			$"weaponanim-output-{Guid.NewGuid():N}",
			"Assets" );
		var document = WeaponAnimationDocument.CreateDefault( "Output Test" );
		var defaultOutput = AssetGenerationService.ResolveOutputRootForContentRoot(
			document,
			contentRoot );
		Equal(
			report,
			Path.GetFullPath( Path.Combine(
				contentRoot,
				"weapons",
				"output_test",
				"viewmodel" ) ),
			defaultOutput,
			"Default generation output must resolve beneath Assets even before the folder exists." );

		document.Output.OutputFolder = "/weapons/custom/viewmodel";
		Equal(
			report,
			Path.GetFullPath( Path.Combine(
				contentRoot,
				"weapons",
				"custom",
				"viewmodel" ) ),
			AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),
			"A leading asset slash must remain a project-relative output path." );

		document.Output.OutputFolder = "../outside";
		var rejectedEscape = false;
		try
		{
			AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot );
		}
		catch ( InvalidOperationException )
		{
			rejectedEscape = true;
		}
		Check(
			report,
			rejectedEscape,
			"Generation output must reject paths that escape the project's Assets folder." );

		document.Output.OutputFolder = "C:/outside";
		var rejectedDrive = false;
		try
		{
			AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot );
		}
		catch ( InvalidOperationException )
		{
			rejectedDrive = true;
		}
		Check(
			report,
			rejectedDrive,
			"Generation output must reject absolute drive paths on every host platform." );

		var nestedOutputRoot = Path.Combine(
			Path.GetTempPath(),
			$"weaponanim-nested-output-{Guid.NewGuid():N}" );
		try
		{
			AssetGenerationService.WriteTextSourcesForTests(
				nestedOutputRoot,
				new Dictionary<string, string>
				{
					["materials/output_test_body.vmat"] = "fixture material",
					["output_test_vm.vmdl"] = "fixture model"
				} );
			Check(
				report,
				File.Exists( Path.Combine(
					nestedOutputRoot,
					"materials",
					"output_test_body.vmat" ) ),
				"Generation must create parent directories for nested material sources." );
		}
		finally
		{
			if ( Directory.Exists( nestedOutputRoot ) )
				Directory.Delete( nestedOutputRoot, true );
		}

		document.Output = null!;
		Equal(
			report,
			Path.GetFullPath( Path.Combine(
				contentRoot,
				"weapons",
				"output_test",
				"viewmodel" ) ),
			AssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),
			"Generation must repair missing output settings instead of throwing." );
	}

	private static void TestGeneratedFileRemoval( WeaponAnimatorSelfTestReport report )
	{
		var root = Path.Combine(
			Path.GetTempPath(),
			$"weaponanim-removal-{Guid.NewGuid():N}" );
		Directory.CreateDirectory( root );
		try
		{
			var host = Path.Combine( root, "weapon_host.vmdl" );
			var clip = Path.Combine( root, "weapon_idle.dmx" );
			var graph = Path.Combine( root, "weapon.vanmgrph" );
			var prefab = Path.Combine( root, "v_weapon.prefab" );
			foreach ( var file in new[] { host, clip, graph, prefab } )
			{
				File.WriteAllText( file, "generated" );
				File.WriteAllText( $"{file}_c", "compiled" );
			}

			AssetGenerationService.DeleteGeneratedFiles( [clip, host, graph, prefab] );

			Check(
				report,
				!File.Exists( host ) && !File.Exists( $"{host}_c" ),
				"Removing a generated asset must take its compiled artifact with it." );
			Check(
				report,
				!File.Exists( clip ) && !File.Exists( graph ) && !File.Exists( prefab ),
				"Every listed generated file must be removed." );

			// The dependant .vmdl has to be gone before its .dmx sources, or the asset system
			// keeps recompiling a model whose animation dependencies stopped existing.
			File.WriteAllText( host, "generated" );
			File.WriteAllText( clip, "generated" );
			var ordered = AssetGenerationService.OrderForRemoval( [clip, host] ).ToList();
			Equal(
				report,
				host,
				ordered[0],
				"Compiled dependants must be removed before the sources they consume." );
				var lifecycleFiles = new[]
				{
					"weapon_sequence_idle.dmx",
					"weapon_source_adapter.vmdl",
					"weapon_vm_bootstrap.vmdl",
					"weapon.vanmgrph",
				"weapon_vm.vmdl",
				"v_weapon.prefab"
			};
			var writeOrder = AssetGenerationService.OrderForWrite( lifecycleFiles ).ToList();
			Equal(
				report,
				string.Join( "|", lifecycleFiles ),
				string.Join( "|", writeOrder ),
				"Generated sources must appear in dependency order so automatic compilation never observes a missing preview host." );
			var removeOrder = AssetGenerationService.OrderForRemoval( lifecycleFiles ).ToList();
			Equal(
					report,
					"v_weapon.prefab|weapon_vm.vmdl|weapon.vanmgrph|weapon_vm_bootstrap.vmdl|weapon_source_adapter.vmdl|weapon_sequence_idle.dmx",
				string.Join( "|", removeOrder ),
				"Generated consumers must be removed in reverse dependency order." );
			Check(
				report,
				!AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
					"weapon_sprint.dmx",
					previouslyOwned: true ),
				"Rollback must retain a recreated owned DMX dependency needed by an older host." );
			Check(
				report,
				AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
					"weapon_vm.vmdl",
					previouslyOwned: true )
				&& AssetGenerationService.ShouldDeleteCreatedFileOnRollback(
					"new_clip.dmx",
					previouslyOwned: false ),
				"Rollback must remove failed compiled consumers and newly introduced dependencies." );

			var freshnessSource = Path.Combine( root, "freshness.vmdl" );
			var freshnessCompiled = freshnessSource + "_c";
			File.WriteAllText( freshnessSource, "source" );
			File.WriteAllText( freshnessCompiled, "compiled" );
			var now = DateTime.UtcNow;
			File.SetLastWriteTimeUtc( freshnessSource, now );
			File.SetLastWriteTimeUtc( freshnessCompiled, now.AddSeconds( 1 ) );
			Check(
				report,
				AssetGenerationService.IsFreshCompiledArtifact(
					freshnessSource,
					freshnessCompiled ),
				"A newly written compiled artifact must complete generation even while its managed Asset wrapper is stale." );
			File.SetLastWriteTimeUtc( freshnessCompiled, now.AddSeconds( -10 ) );
			Check(
				report,
				!AssetGenerationService.IsFreshCompiledArtifact(
					freshnessSource,
					freshnessCompiled ),
				"An artifact older than its regenerated source must never be accepted as compile success." );
		}
		finally
		{
			Directory.Delete( root, true );
		}
	}

	private static void TestMaterialPipeline( WeaponAnimatorSelfTestReport report )
	{
		var embeddedMaterials = WeaponMaterialPipeline.MatchEmbeddedMaterialNamesForTests(
			["HK_P30L", "cartridge"],
			["Material", "H&K_P30L", "cartridge", "cartridge_BaseColor"] );
		Check(
			report,
			embeddedMaterials.Contains( "H&K_P30L" )
				&& embeddedMaterials.Contains( "cartridge" )
				&& embeddedMaterials.Count == 2,
			"Embedded FBX labels must preserve special characters when matching texture-set names." );

		var discovered = WeaponMaterialPipeline.DiscoverForTests(
			[
				"H&K_P30L.vmat",
				"cartridge.vmat",
				"materials/error.vmat"
			],
			[
				"/fixture/Textures/HK_P30L_BaseColor.png",
				"/fixture/Textures/HK_P30L_Normal_GL.png",
				"/fixture/Textures/HK_P30L_Normal_DX.png",
				"/fixture/Textures/HK_P30L_Roughness.png",
				"/fixture/Textures/HK_P30L_Metallic.png",
				"/fixture/Textures/cartridge_BaseColor.png",
				"/fixture/Textures/cartridge_Normal_DX.png"
			] );
		Equal(
			report,
			2,
			discovered.Count,
			"Nearby texture discovery must retain every FBX material slot." );
		var pistol = discovered.Single( material => material.Name == "H&K_P30L" );
		Check(
			report,
			pistol.FindTexture( WeaponTextureChannel.Normal )?.AssetPath
				.EndsWith( "Normal_GL.png", StringComparison.OrdinalIgnoreCase ) == true,
			"S&box-compatible OpenGL normal maps must win when both GL and DX variants are available." );
		Check(
			report,
			pistol.FindTexture( WeaponTextureChannel.Metalness ) is not null
				&& discovered.Single( material => material.Name == "cartridge" )
					.FindTexture( WeaponTextureChannel.BaseColor ) is not null,
			"Texture sets must be matched independently to their source material names." );
		Check(
			report,
			discovered.All( material => !material.SourceMaterialPath.Equals(
				"materials/error",
				StringComparison.OrdinalIgnoreCase ) ),
			"The compiler error material must never become a generated weapon material slot." );
		Check(
			report,
			discovered.All( material => !Path.HasExtension( material.SourceMaterialPath ) ),
			"Stored source material labels must not look like GameResource dependencies." );
		Equal(
			report,
			"weaponanim_preview_cache/0123456789abcdef",
			WeaponMaterialPipeline.LegalPreviewRelativeRootForTests(
				"/fixture/Assets/.weaponanim-cache/0123456789abcdef" ),
			"Preview materials must use a legal non-hidden asset namespace." );
		var originalRevision = WeaponMaterialPipeline.PreviewRevision( discovered );
		pistol.Textures[0].Sha256 = "changed-image-hash";
		var changedRevision = WeaponMaterialPipeline.PreviewRevision( discovered );
		Check(
			report,
			!originalRevision.Equals( changedRevision, StringComparison.Ordinal ),
			"A changed texture input must create a new immutable preview revision." );
		pistol.Textures[0].Sha256 = "";

		var document = ValidDocument();
		document.Source.Materials = discovered.ToList();
		var generated = WeaponMaterialPipeline.BuildOutputTextFiles(
			document,
			"weapons/test_weapon/viewmodel" );
		var material = generated["materials/test_weapon_h_k_p30l.vmat"];
		Check(
			report,
			material.Contains( "F_SPECULAR 1", StringComparison.Ordinal )
				&& material.Contains( "F_METALNESS_TEXTURE 1", StringComparison.Ordinal )
				&& material.Contains( "TextureMetalness", StringComparison.Ordinal )
				&& material.Contains(
					"test_weapon_h_k_p30l_metalness.png",
					StringComparison.Ordinal ),
			"Generated weapon VMATs must enable specular and mapped metalness." );
		Check(
			report,
			generated.Keys.Count( path => path.EndsWith(
				".vtex",
				StringComparison.OrdinalIgnoreCase ) ) == 0
				&& material.Contains(
					"test_weapon_h_k_p30l_color.png",
					StringComparison.Ordinal )
				&& material.Contains(
					"test_weapon_h_k_p30l_normal.png",
					StringComparison.Ordinal ),
			"VMATs must reference image inputs directly so S&box can build native generated VTEX resources." );
		document.Source.NeedsModelDocWrapper = true;
		pistol.PreviewMaterialPath =
			".weaponanim-cache/fixture/materials/h_k_p30l.vmat";
		Check(
			report,
			WeaponMaterialPipeline.RequiresPreviewRefresh( document ),
			"Legacy hidden preview material paths must force a safe material refresh." );
		foreach ( var binding in discovered.Where( binding => binding.HasUsableTextures ) )
		{
			binding.PreviewMaterialPath =
				$"weaponanim_preview_cache/fixture/revision/materials/{binding.OutputName}.vmat";
		}
		Check(
			report,
			!WeaponMaterialPipeline.RequiresPreviewRefresh( document ),
			"Legal compiled preview material paths must not refresh repeatedly." );
		var serializedDocument = Json.Serialize( document );
		Check(
			report,
			!serializedDocument.Contains(
				"PreviewMaterialPath",
				StringComparison.Ordinal )
				&& !serializedDocument.Contains(
					"H&K_P30L.vmat",
					StringComparison.OrdinalIgnoreCase ),
			"Transient preview VMATs and source slot extensions must stay out of .wepanim serialization." );

		var legacyMaterialDocument = WeaponAnimationDocument.CreateDefault();
		legacyMaterialDocument.Source.Materials =
		[
			new SourceMaterialBinding
			{
				SourceMaterialPath = "cartridge.vmat",
				Name = "cartridge",
				OutputName = "cartridge"
			}
		];
		var materialMigration = WeaponAnimationMigration.MigrateAndRepair(
			legacyMaterialDocument );
		Check(
			report,
			materialMigration.RepairedMaterialMetadata
				&& legacyMaterialDocument.Source.Materials[0].SourceMaterialPath
					.Equals( "cartridge", StringComparison.Ordinal ),
			"Opening an existing project must remove false VMAT dependencies from source slot metadata." );

		var recoveredCandidate = WeaponSourceImporter.SelectRecoveryCandidateForTests(
		[
			new(
				"/preview/newer-uncompiled/models/source_abc_textured.vmdl",
				new DateTime( 2026, 7, 28, 20, 0, 0, DateTimeKind.Utc ),
				false,
				true ),
			new(
				"/preview/legacy/models/source_abc_textured.vmdl",
				new DateTime( 2026, 7, 28, 19, 0, 0, DateTimeKind.Utc ),
				true,
				false ),
			new(
				"/preview/versioned/models/source_abc_textured.vmdl",
				new DateTime( 2026, 7, 28, 18, 0, 0, DateTimeKind.Utc ),
				true,
				true )
		] );
		Equal(
			report,
			"/preview/versioned/models/source_abc_textured.vmdl",
			recoveredCandidate,
			"Missing saved source wrappers must recover to a compiled immutable preview revision." );
		Check(
			report,
			!WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(
				"weaponanim_preview_cache/document/source.vmdl",
				"weaponanim_preview_cache/document/source.vmdl" )
				&& WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(
					"weaponanim_preview_cache/document/repaired.vmdl",
					"weaponanim_preview_cache/document/source.vmdl" ),
			"A failed source load must not rebuild the private scene every frame, "
				+ "but a repaired path must trigger one rebuild." );

		var remaps = WeaponMaterialPipeline.OutputRemaps(
			document,
			"weapons/test_weapon/viewmodel" );
		Equal(
			report,
			2,
			remaps.Count,
			"Final generation must preserve separate material-slot remaps." );
		var host = ModelDocWriter.WriteHost(
			"host_reference.dmx",
			[],
			"",
			["weapon_root"],
			new HostWeaponMesh(
				"source.fbx",
				"weapon_root",
				Transform.Zero,
				[],
				remaps ) );
		Check(
			report,
			host.Contains( "use_global_default = false", StringComparison.Ordinal )
				&& !host.Contains( "use_global_default = true", StringComparison.Ordinal )
				&& host.Contains( "from = \"H&K_P30L.vmat\"", StringComparison.Ordinal )
				&& host.Contains( "from = \"cartridge.vmat\"", StringComparison.Ordinal ),
			"Weapon ModelDocs must use per-slot remaps with global material override disabled." );
	}

	private static void TestRebase( WeaponAnimatorSelfTestReport report )
	{
		var document = WeaponAnimationDocument.CreateDefault();
		document.Rig.RootBone = "weapon_root";
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		var rootTrack = idle.EnsureTrack( "weapon_root" );
		WeaponAnimationMath.UpsertKey( rootTrack, 0, new Transform( new Vector3( 2, 0, 0 ) ) );
		var previous = new CalibrationSnapshot
		{
			PhysicalTransform = Transform.Zero,
			FramingTransform = Transform.Zero
		};
		document.Calibration.PhysicalTransform = new Transform( new Vector3( 10, 0, 0 ) );
		CalibrationRebaser.RebaseAnimationData( document, previous );
		Near( report, 12, rootTrack.Keys[0].Position.x, 0.001f, "Root keys must retain their placement-relative offset." );
	}

	private static void TestDmxOutput( WeaponAnimatorSelfTestReport report )
	{
		var skeleton = new HostSkeleton();
		skeleton.Add( Bone( "root", "", Vector3.Zero ) );
		skeleton.Add( Bone( "weapon_root", "root", Vector3.Forward ) );
		var first = DmxWriter.WriteReference( skeleton );
		var second = DmxWriter.WriteReference( skeleton );

		Check( report, first.StartsWith( "<!-- dmx encoding keyvalues2 4 format model 22 -->" ), "Host reference must use ModelDoc's supported DMX model format." );
		Check( report, first.Contains( "\"name\" \"string\" \"weapon_root\"" ), "Host reference must include every skeleton bone." );
		Check(
			report,
			first.Contains( "\"element\" \"" + DmxJointIdForTest( 0 ) + "\"," ),
			"DMX element array entries must be comma-delimited." );
		var blendIndices = first[first.IndexOf( "\"blendindices$0\" \"int_array\"", StringComparison.Ordinal )..];
		Check(
			report,
			blendIndices.Contains( "\t\t\"1\",\n\t\t\"1\",\n\t\t\"1\"\n", StringComparison.Ordinal ),
			"The carrier mesh must reference every host bone so ModelDoc cannot cull the skeleton." );
		Check(
			report,
			first.Contains( "\t\t\t\t\"3\",\n\t\t\t\t\"4\",\n\t\t\t\t\"5\",\n\t\t\t\t\"-1\"\n", StringComparison.Ordinal ),
			"The carrier mesh must emit one triangle per host bone." );
		Check(
			report,
			first.Contains( "materials/tools/toolsinvisible.vmat", StringComparison.Ordinal ),
			"The bone-retention carrier must use an invisible material." );
		Check(
			report,
			first.Contains( "\"forwardParity\" \"int\" \"1\"", StringComparison.Ordinal )
				&& !first.Contains( "\"forwardParity\" \"int\" \"-2\"", StringComparison.Ordinal ),
			"Reference DMX must use the Source 2 Z-up axis parity expected by ModelDoc." );
		Equal( report, first, second, "DMX host references must be deterministic." );
		var document = WeaponAnimationDocument.CreateDefault();
		document.Binding.Configuration = GripConfiguration.OneHanded;
		var clip = document.EnsureClip( WeaponClipRole.Idle );
		clip.Duration = 1;
		clip.SampleRate = 30;
		var animation = DmxWriter.WriteAnimation( document, skeleton, clip );
		Check(
			report,
			animation.Contains( "\"DmeChannelsClip\"", StringComparison.Ordinal )
				&& animation.Contains( "\"DmeVector3LogLayer\"", StringComparison.Ordinal )
				&& animation.Contains( "\"DmeQuaternionLogLayer\"", StringComparison.Ordinal )
				&& animation.Contains( "\"DmeFloatLogLayer\"", StringComparison.Ordinal ),
			"DMX animation output must contain position, rotation, and scale channels." );
		Check(
			report,
			animation.Contains( "\"DmeJoint\"", StringComparison.Ordinal )
				&& !animation.Contains( "\"DmeDag\"", StringComparison.Ordinal ),
			"Animation skeleton entries must be Source 2 joints rather than generic DAG nodes." );
		Check(
			report,
			animation.Contains( "\"DmeTransformList\"", StringComparison.Ordinal )
				&& animation.Contains( "\"baseStates\" \"element_array\"", StringComparison.Ordinal ),
			"Animation DMX must include a bind transform list for ModelDoc sequence import." );
		Check(
			report,
			animation.Contains( "\"mode\" \"int\" \"1\"", StringComparison.Ordinal ),
			"Animation channels must use the Source 2 exporter channel mode." );
		Check(
			report,
			animation.Contains( "\"jointList\" \"element_array\"", StringComparison.Ordinal )
				&& animation.Contains(
					"\"element\" \"" + DmxAnimationJointIdForTest( clip, 0 ) + "\"",
					StringComparison.Ordinal ),
			"Animation DMX must register every animated joint with its model." );
		Check(
			report,
			animation.Contains( "\t\t\"1\"\n", StringComparison.Ordinal ),
			"A one-second animation must include its final sample time." );
		Check(
			report,
			!animation.Contains( "NaN", StringComparison.OrdinalIgnoreCase )
				&& !animation.Contains( "Infinity", StringComparison.OrdinalIgnoreCase ),
			"DMX animation output must contain finite transforms." );
		Check(
			report,
			animation.Contains( "\"forwardParity\" \"int\" \"1\"", StringComparison.Ordinal )
				&& !animation.Contains( "\"forwardParity\" \"int\" \"-2\"", StringComparison.Ordinal ),
			"Animation DMX must use the same Source 2 axis system as its host reference." );
		Equal(
			report,
			animation,
			DmxWriter.WriteAnimation( document, skeleton, clip ),
			"DMX animation output must be deterministic." );

		var scaledSkeleton = new HostSkeleton();
		scaledSkeleton.Add( new HostBone
		{
			Name = "root",
			BindModelTransform = Transform.Zero,
			BindLocalTransform = Transform.Zero,
			HasExplicitBindLocal = true
		} );
		scaledSkeleton.Add( new HostBone
		{
			Name = "weapon_root",
			ParentName = "root",
			BindModelTransform = new Transform(
				Vector3.Zero,
				Rotation.Identity,
				Vector3.One * 0.56f ),
			BindLocalTransform = new Transform(
				Vector3.Zero,
				Rotation.Identity,
				Vector3.One * 0.56f ),
			HasExplicitBindLocal = true,
			IsWeaponBone = true
		} );
		scaledSkeleton.Add( new HostBone
		{
			Name = "hammer",
			ParentName = "weapon_root",
			BindModelTransform = new Transform(
				new Vector3( 0, -5.75f, 0.2f ) * 0.56f ),
			BindLocalTransform = new Transform( new Vector3( 0, -5.75f, 0.2f ) ),
			HasExplicitBindLocal = true,
			IsWeaponBone = true
		} );
		var hammerTrack = clip.EnsureTrack( "hammer" );
		var hammerRotation = Rotation.FromPitch( 45 );
		WeaponAnimationMath.UpsertKey(
			hammerTrack,
			0,
			new Transform( new Vector3( 0, -5.75f, 0.2f ), hammerRotation ) );
		var scaledPose = AnimationPoseEvaluator.Evaluate(
			document,
			scaledSkeleton,
			clip,
			0 );
		var exportedPose = DmxWriter.BuildCompilerPoseLocals(
			scaledSkeleton,
			scaledPose.Local );
		var exportedRoot = exportedPose["weapon_root"];
		var exportedHammer = exportedPose["hammer"];
		Near(
			report,
			Vector3.One,
			exportedRoot.Scale,
			0.0001f,
			"Animation export must use ModelDoc's scale-one compiled bind space." );
		Near(
			report,
			new Vector3( 0, -5.75f, 0.2f ) * 0.56f,
			exportedHammer.Position,
			0.0001f,
			"Rotating a weapon child must use the physical scale-baked mesh pivot in compiled bind space." );
		Near(
			report,
			hammerRotation.Forward,
			exportedHammer.Rotation.Forward,
			0.0001f,
			"Rotating a weapon child must retain its authored local rotation in compiled bind space." );
		var scaledAnimation = DmxWriter.WriteAnimation(
			document,
			scaledSkeleton,
			clip );
		var scaledReference = DmxWriter.WriteReference( scaledSkeleton );
		Check(
			report,
			!scaledAnimation.Contains(
				"\"scale\" \"float\" \"0.56\"",
				StringComparison.Ordinal ),
			"Animation bind declarations must not reintroduce source scale after ModelDoc bakes it into the host." );
		Check(
			report,
			scaledReference.Contains(
				"\"position\" \"vector3\" \"0 -3.22 0.112\"",
				StringComparison.Ordinal )
				&& scaledAnimation.Contains(
					"\"position\" \"vector3\" \"0 -3.22 0.112\"",
					StringComparison.Ordinal ),
			"Reference and animation skeletons must share the scale-baked physical pivot of rotating weapon children." );

		document.Manifest.Files.Add( new GeneratedFileRecord
		{
			RelativePath = "generated_sequence.dmx"
		} );
		Check(
			report,
			!Json.Serialize( document ).Contains( "\"Manifest\"", StringComparison.Ordinal ),
			"The creative document must not serialize generated filenames as resource dependencies." );
		var wrapper = ModelDocWriter.WriteSourceWrapper( "weapon.fbx", "root" );
		Check(
			report,
			wrapper.Contains( "original_bone_name = \"root\"" )
				&& wrapper.Contains( "new_bone_name = \"weapon_root\"" ),
			"Source wrappers must normalize the selected weapon root." );
		var host = ModelDocWriter.WriteHost(
			"host_reference.dmx",
			[],
			"weapon.vanmgrph",
			skeleton.Bones.Select( bone => bone.Name ),
			new HostWeaponMesh(
				"weapon.fbx",
				"root",
				new Transform( Vector3.Zero, Rotation.Identity, Vector3.One * 0.6f ),
				[],
				[
					new HostMaterialRemap(
						"frame.vmat",
						"weapons/test/materials/frame.vmat" )
				] ),
			[
				new HostAttachment(
					"muzzle",
					"weapon_root",
					Vector3.Forward * 10,
					Rotation.Identity )
			] );
		Check(
			report,
			host.Contains( "target_bone = \"weapon_root\"", StringComparison.Ordinal )
				&& host.Contains( "do_not_discard = true", StringComparison.Ordinal )
				&& host.Contains( "filename = \"weapon.fbx\"", StringComparison.Ordinal )
				&& host.Contains( "import_scale = 0.6", StringComparison.Ordinal )
				&& host.Contains( "anim_graph_name = \"weapon.vanmgrph\"", StringComparison.Ordinal )
				&& host.Contains( "use_global_default = false", StringComparison.Ordinal )
				&& !host.Contains( "use_global_default = true", StringComparison.Ordinal )
				&& host.Contains( "from = \"frame.vmat\"", StringComparison.Ordinal )
				&& host.Contains( "from = \"materials/tools/toolsinvisible.vmat\"", StringComparison.Ordinal )
				&& host.Contains( "_class = \"Attachment\"", StringComparison.Ordinal )
				&& host.Contains( "name = \"muzzle\"", StringComparison.Ordinal ),
			"Host ModelDocs must preserve generated bones, safely handle imported materials, and contain the visible weapon, graph, and attachments." );
		var skeletonOnlyHost = ModelDocWriter.WriteHost(
			"host_reference.dmx",
			[],
			"",
			skeleton.Bones.Select( bone => bone.Name ) );
		Check(
			report,
			skeletonOnlyHost.Contains( "use_global_default = false", StringComparison.Ordinal ),
			"A skeleton-only host must retain the invisible carrier material without substitution." );
	}

	private static void TestFilteredSourceWrapper( WeaponAnimatorSelfTestReport report )
	{
		var wrapper = ModelDocWriter.WriteSourceWrapper(
			"weapons/test/source.fbx",
			"Armature",
			["foreign_arm", "foreign_camera"] );
		Check( report, wrapper.Contains( "_class = \"RenameBone\"" ), "A tool-owned source wrapper must normalize the root without modifying the original source." );
		Check( report, wrapper.Contains( "_class = \"RemoveBoneAndChildren\"" ), "A filtered source wrapper must remove excluded branch roots." );
		Check( report, wrapper.Contains( "\"foreign_arm\"" ) && wrapper.Contains( "\"foreign_camera\"" ), "Every excluded branch root must be emitted deterministically." );
		var vmdl = $"{ModelDocWriter.Header}\n{{ rootNode = {{ _class = \"RootNode\" children = [ ] }} }}";
		var adapted = ModelDocWriter.WriteVmdlSourceAdapter( vmdl, "root", ["foreign_arm"] );
		Check(
			report,
			adapted.Contains( "_class = \"ModelModifierList\"" )
				&& adapted.Contains( "original_bone_name = \"root\"" )
				&& adapted.Contains( "\"foreign_arm\"" ),
			"VMDL inputs must receive the same tool-owned root normalization and branch filtering." );
	}

	private static void TestGenerationSourceAdapters( WeaponAnimatorSelfTestReport report )
	{
		var source = $$"""
			{{ModelDocWriter.Header}}
			{
				rootNode =
				{
					_class = "RootNode"
					children =
					[
						{
							_class = "RenderMeshFile"
							filename = "receiver.fbx"
							import_translation = [ 2, 0, 0 ]
							import_rotation = [ 0, 0, 0 ]
							import_scale = 1
						},
						{
							_class = "RenderMeshFile"
							filename = "magazine.fbx"
							import_translation = [ 0, 2, 0 ]
							import_rotation = [ 0, 0, 0 ]
							import_scale = 1
						},
					]
				}
			}
			""";
		var adapted = ModelDocWriter.WriteVmdlSourceAdapter(
			source,
			"root",
			["foreign_arm"],
			new Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, 0.5f ) );
		Check(
			report,
			Count( adapted, "import_scale = 0.5" ) == 2
				&& adapted.Contains( "import_translation = [ 11, 0, 0 ]", StringComparison.Ordinal )
				&& adapted.Contains( "import_translation = [ 10, 1, 0 ]", StringComparison.Ordinal )
				&& adapted.Contains( "original_bone_name = \"root\"", StringComparison.Ordinal )
				&& adapted.Contains( "\"foreign_arm\"", StringComparison.Ordinal ),
			"A VMDL adapter must apply calibration to every render mesh while preserving filtering." );

		var baseHost = ModelDocWriter.WriteHost(
			"reference.dmx",
			[],
			"",
			["weapon_root"],
			baseModelPath: "weapons/test/source_adapter.vmdl" );
		Check(
			report,
			baseHost.Contains(
				"base_model_name = \"weapons/test/source_adapter.vmdl\"",
				StringComparison.Ordinal ),
			"Generated hosts must be able to derive their visible mesh from a VMDL adapter." );

		var temporary = Path.Combine(
			Path.GetTempPath(),
			$"weaponanim-source-{Guid.NewGuid():N}.vmdl" );
		File.WriteAllText( temporary, source );
		try
		{
			var document = ValidDocument();
			document.Source.SourcePath = temporary;
			document.Source.CompiledModelPath = temporary;
			document.Calibration.PhysicalTransform =
				new Transform( Vector3.Zero, Rotation.Identity, 0.6f );
			var progress = new List<GenerationProgress>();
			var generated = AssetGenerationService.BuildFiles(
				document,
				HostSkeletonBuilder.Build( document, includeArmProfile: false ),
				"weapons/test_weapon/viewmodel",
				progress.Add );
			Check(
				report,
				generated.ContainsKey( "test_weapon_source_adapter.vmdl" )
					&& generated["test_weapon_vm.vmdl"].Contains(
						"base_model_name = \"weapons/test_weapon/viewmodel/test_weapon_source_adapter.vmdl\"",
						StringComparison.Ordinal ),
				"VMDL source projects must generate a persistent calibrated adapter." );
			Check(
				report,
				progress.Any( item =>
					item.Stage == "Sequences"
					&& item.Completed == 1
					&& item.Total == 1 ),
				"Generation must report deterministic per-sequence progress." );
			using var cancellation = new System.Threading.CancellationTokenSource();
			cancellation.Cancel();
			var cancelled = false;
			try
			{
				AssetGenerationService.BuildFiles(
					document,
					HostSkeletonBuilder.Build( document, includeArmProfile: false ),
					"weapons/test_weapon/viewmodel",
					cancellationToken: cancellation.Token );
			}
			catch ( OperationCanceledException )
			{
				cancelled = true;
			}
			Check(
				report,
				cancelled,
				"Generation must honor cancellation before assembling or replacing output files." );
			cancelled = false;
			try
			{
				DmxWriter.WriteAnimation(
					document,
					HostSkeletonBuilder.Build( document, includeArmProfile: false ),
					document.EnsureClip( WeaponClipRole.Idle ),
					cancellation.Token );
			}
			catch ( OperationCanceledException )
			{
				cancelled = true;
			}
			Check(
				report,
				cancelled,
				"DMX frame sampling must observe cancellation inside the worker-safe generation path." );
		}
		finally
		{
			File.Delete( temporary );
		}
	}

	private static string DmxJointIdForTest( int index )
	{
		var bytes = System.Security.Cryptography.SHA256.HashData(
			System.Text.Encoding.UTF8.GetBytes( $"SboxWeaponAnimator.DmxReference:joint:{index}" ) );
		return new Guid( bytes.AsSpan( 0, 16 ) ).ToString();
	}

	private static string DmxAnimationJointIdForTest(
		WeaponAnimationClip clip,
		int index )
	{
		var key =
			$"SboxWeaponAnimator.DmxReference:animation:{clip.Id}:joint:{index}";
		var bytes = System.Security.Cryptography.SHA256.HashData(
			System.Text.Encoding.UTF8.GetBytes( key ) );
		return new Guid( bytes.AsSpan( 0, 16 ) ).ToString();
	}

	private static void TestDeterministicOutput( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		var originalCulture = CultureInfo.CurrentCulture;
		try
		{
			CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( "fr-FR" );
				var graphFrench = AnimGraphWriter.Write( document, "weapons/test/host.vmdl" );
				var modelFrench = ModelDocWriter.WriteHost(
					"host_reference.dmx",
					[(idle, "idle.dmx")],
					"weapon.vanmgrph",
					["root", "weapon_root"] );
			var prefabFrench = PrefabWriter.Write( document, "host.vmdl" );

			CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( "en-US" );
			Equal( report, graphFrench, AnimGraphWriter.Write( document, "weapons/test/host.vmdl" ), "AnimGraph output must be culture-independent." );
			Equal(
					report,
					modelFrench,
					ModelDocWriter.WriteHost(
						"host_reference.dmx",
						[(idle, "idle.dmx")],
						"weapon.vanmgrph",
						["root", "weapon_root"] ),
				"ModelDoc output must be culture-independent." );
			Equal( report, prefabFrench, PrefabWriter.Write( document, "host.vmdl" ), "Prefab output must be culture-independent." );
			Equal( report, AnimGraphWriter.Id( "node:Root" ), AnimGraphWriter.Id( "node:Root" ), "Deterministic graph IDs must be stable." );
		}
		finally
		{
			CultureInfo.CurrentCulture = originalCulture;
		}
	}

	private static void TestAnimGraphTagsAndFallbacks( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		idle.Tags.Add( new AnimationTag
		{
			Name = "attack_discouraged",
			Kind = AnimationTagKind.Range,
			StartTime = 0.2f,
			EndTime = 0.6f
		} );
		var graph = AnimGraphWriter.Write( document, "host.vmdl" );
		Check( report, graph.Contains( "_class = \"CAnimTagSpan\"" ), "Authored tags must become sequence tag spans." );
		Check( report, graph.Contains( "m_fStartCycle = 0.2" ), "Tag start time must be normalized to sequence cycle." );
		Check(
			report,
			Count( graph, "m_sequenceName = \"idle\"" ) > 1,
			"Missing action clips must use Idle sequence fallbacks." );
		Check( report, graph.Contains( "m_name = \"b_attack\"" ), "Facepunch firearm parameters must be exposed." );
		Check( report, graph.Contains( "m_name = \"reload_increment\"" ), "Standard reload tags must be declared." );

		var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
		var generated = AssetGenerationService.BuildFiles(
			document,
			skeleton,
			"weapons/test_weapon/viewmodel" );
		var finalHost = generated["test_weapon_vm.vmdl"];
		var bootstrapHost = generated["test_weapon_vm_bootstrap.vmdl"];
		var generatedGraph = generated["test_weapon.vanmgrph"];
		Check(
			report,
			finalHost.Contains(
				"anim_graph_name = \"weapons/test_weapon/viewmodel/test_weapon.vanmgrph\"",
				StringComparison.Ordinal )
				&& bootstrapHost.Contains( "anim_graph_name = \"\"", StringComparison.Ordinal )
				&& generatedGraph.Contains(
					"m_previewModels = [ \"weapons/test_weapon/viewmodel/test_weapon_vm_bootstrap.vmdl\", ]",
					StringComparison.Ordinal ),
			"Generation must keep a permanent graph-free preview host while the final host always links its AnimGraph." );
		Check(
			report,
			generated.ContainsKey( "test_weapon_sequence_idle.dmx" )
				&& !generated.ContainsKey( "test_weapon_idle.dmx" )
				&& !generated.ContainsKey( "test_weapon_sequence_fire.dmx" ),
			"Generation must emit authored sequences only and leave missing action roles on Idle fallbacks." );

		var custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );
		custom.Name = "Mechanical Check";
		custom.Readiness = ClipReadiness.Draft;
		document.Clips.Add( custom );
		WeaponAnimationNames.RepairCustomSequenceNames( document );
		generated = AssetGenerationService.BuildFiles(
			document,
			skeleton,
			"weapons/test_weapon/viewmodel" );
		Check(
			report,
			generated.ContainsKey(
				$"test_weapon_sequence_{custom.GeneratedSequenceName}.dmx" ),
			"Authored custom clips must use their persisted readable sequence name." );
	}

	private static void TestPartVisibility( WeaponAnimatorSelfTestReport report )
	{
		var document = ValidDocument();
		var idle = document.EnsureClip( WeaponClipRole.Idle );
		var part = new WeaponVisibilityPart
		{
			Name = "Spare Magazine",
			BoneId = "weapon_root",
			BoneName = "weapon_root",
			DefaultVisible = false
		};
		document.Rig.VisibilityParts.Add( part );
		var track = idle.EnsureVisibilityTrack( part.Id );
		var show = WeaponVisibilityEvaluator.UpsertKey( track, 0.2f, true );
		WeaponVisibilityEvaluator.UpsertKey( track, 0.8f, false );

		Check(
			report,
			!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.1f )
				&& WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f )
				&& !WeaponVisibilityEvaluator.Evaluate( part, idle, 0.9f ),
			"Visibility tracks must evaluate as stepped state changes from the configured default." );
		var replacement = WeaponVisibilityEvaluator.UpsertKey( track, 0.2f, false );
		Equal(
			report,
			show.Id,
			replacement.Id,
			"Keying visibility twice at one frame must update the existing key." );
		Check(
			report,
			!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f ),
			"A replaced visibility key must take effect immediately." );
		replacement.Visible = true;

		var spans = WeaponVisibilityEvaluator.BuildSpans( part, idle );
		Equal( report, 3, spans.Count, "Visibility export must cover the full clip with deterministic state spans." );
		Near( report, 0, spans[0].StartTime, 0.0001f, "The first visibility span must begin at clip start." );
		Near( report, idle.Duration, spans[^1].EndTime, 0.0001f, "The final visibility span must reach clip end." );

		var skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );
		var before = DmxWriter.WriteAnimation( document, skeleton, idle );
		var graph = AnimGraphWriter.Write( document, "host.vmdl" );
		var after = DmxWriter.WriteAnimation( document, skeleton, idle );
		Equal(
			report,
			before,
			after,
			"Visibility export must be deterministic and must not mutate authored transforms." );
		Check(
			report,
			before.Contains( "\"DmeFloatLogLayer\"", StringComparison.Ordinal )
				&& before.Contains( "\"0.0001\"", StringComparison.Ordinal )
				&& before.Contains( "-8192", StringComparison.Ordinal ),
			"Bone visibility must use native sequence scale and off-screen position channels." );
		Check(
			report,
			graph.Contains( WeaponVisibilityEvaluator.VisibleTag( part.Id ) )
				&& graph.Contains( WeaponVisibilityEvaluator.HiddenTag( part.Id ) ),
			"Generated AnimGraphs must declare both visibility states for every part." );

		var prefab = PrefabWriter.Write( document, "host.vmdl" );
		Check(
			report,
			!prefab.Contains( "WeaponPartVisibilityController", StringComparison.Ordinal )
				&& !prefab.Contains( "\"Name\": \"source_weapon\"", StringComparison.Ordinal )
				&& prefab.Contains( "\"Model\": \"host.vmdl\"", StringComparison.Ordinal )
				&& prefab.Contains( "\"GameLayer\": true", StringComparison.Ordinal )
				&& Count( prefab, "\"__type\": \"Sandbox.SkinnedModelRenderer\"" ) == 2
				&& prefab.Contains( "\"Name\": \"muzzle\"", StringComparison.Ordinal )
				&& prefab.Contains( "\"Name\": \"eject\"", StringComparison.Ordinal ),
			"Generated prefabs must use one visible host renderer plus bone-merged arms and explicit output anchors, with no custom controller." );
		document.Output.GenerateGraph = false;
		var graphFreePrefab = PrefabWriter.Write( document, "host.vmdl" );
		Check(
			report,
			graphFreePrefab.Contains( "\"UseAnimGraph\": false", StringComparison.Ordinal )
				&& !graphFreePrefab.Contains( "WeaponPartVisibilityController", StringComparison.Ordinal ),
			"Graph-free prefabs must remain standard and disable AnimGraph playback." );
		document.Output.GenerateGraph = true;

		var controller = new WeaponAnimatorController();
		controller.SetDocument( document );
		controller.SetTimelineTime( 0.2f );
		controller.SelectKeys( [show.Id], false );
		controller.CopySelectedKeys();
		controller.SetTimelineTime( 0.5f );
		controller.PasteKeys();
		Check(
			report,
			idle.VisibilityTracks.Single( x => x.PartId == part.Id )
				.Keys.Any( x => MathF.Abs( x.Time - 0.5f ) <= 0.0001f ),
			"Visibility keys must participate in the shared copy and paste workflow." );

		part.RenderMode = VisibilityRenderMode.BodyGroup;
		part.BodyGroupName = "";
		var invalid = WeaponAnimationValidator.ValidateForGeneration( document );
		Check(
			report,
			invalid.Issues.Any( x => x.Code == "visibility.bodygroup_missing" )
				&& invalid.Issues.Any( x => x.Code == "visibility.bodygroup_export" ),
			"Generation validation must reject bodygroup visibility until it can be baked into a standard prefab." );
	}

	private static WeaponAnimationDocument ValidDocument()
	{
		var document = WeaponAnimationDocument.CreateDefault( "Test Weapon" );
		document.Source.SourcePath = "weapons/test/source.fbx";
		document.Source.CompiledModelPath = "weapons/test/source.vmdl";
		document.Source.Compiled = true;
		document.Source.PreviewHostCompiled = true;
		document.Rig.RootBone = "weapon_root";
		document.Rig.Bones.Add( new WeaponBoneDefinition
		{
			Id = "weapon_root",
			HierarchyPath = "weapon_root",
			Name = "weapon_root",
			OriginalName = "weapon_root",
			Classification = WeaponBoneClassification.WeaponRoot,
			Inclusion = WeaponBoneInclusion.Included,
			BindTransform = Transform.Zero,
			BindModelTransform = Transform.Zero,
			BindLocalTransform = Transform.Zero,
			HasSkinInfluence = true
		} );
		document.Rig.SourceSkeletonRootId = "weapon_root";
		document.Rig.WeaponSubtreeRootId = "weapon_root";
		document.Rig.ReviewRequired = false;
		document.Rig.FilteredPreviewConfirmed = true;
		document.Calibration.SetAnchor( Anchor( AnchorKind.Grip, new Vector3( 1, 0, 0 ) ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.RearBore, Vector3.Zero ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.FrontBore, Vector3.Forward ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.Muzzle, new Vector3( 12, 0, 1 ) ) );
		document.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 4, -1, 2 ) ) );
		document.Calibration.Confirmed = true;
		document.Calibration.Snapshot = new CalibrationSnapshot();
		document.EnsureClip( WeaponClipRole.Idle ).Readiness = ClipReadiness.Ready;
		return document;
	}

	private static WeaponAnchor Anchor( AnchorKind kind, Vector3 position ) => new()
	{
		Name = kind.ToString(),
		Kind = kind,
		BoneName = "weapon_root",
		LocalPosition = position
	};

	private static WeaponBoneDefinition Definition(
		string name,
		string parent,
		WeaponBoneClassification classification,
		Vector3 modelPosition ) =>
		Definition( name, parent, classification, new Transform( modelPosition ) );

	private static WeaponBoneDefinition Definition(
		string name,
		string parent,
		WeaponBoneClassification classification,
		Transform modelTransform ) => new()
	{
		Name = name,
		ParentName = parent,
		OriginalName = name,
		OriginalParentName = parent,
		Classification = classification,
		Inclusion = WeaponBoneInclusion.Included,
		BindTransform = modelTransform,
		BindModelTransform = modelTransform,
		HasSkinInfluence = true
	};

	private static HostBone Bone( string name, string parent, Vector3 position ) => new()
	{
		Name = name,
		ParentName = parent,
		BindModelTransform = new Transform( position )
	};

	private static float RotationLength( Rotation value ) =>
		MathF.Sqrt( value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w );

	private static int Count( string value, string fragment )
	{
		var count = 0;
		var offset = 0;
		while ( (offset = value.IndexOf( fragment, offset, StringComparison.Ordinal )) >= 0 )
		{
			count++;
			offset += fragment.Length;
		}
		return count;
	}

	private static void Run(
		WeaponAnimatorSelfTestReport report,
		string name,
		Action<WeaponAnimatorSelfTestReport> test )
	{
		try
		{
			test( report );
		}
		catch ( Exception ex )
		{
			report.Failures.Add( $"{name}: threw {ex.GetType().Name}: {ex.Message}" );
		}
	}

	private static void Check(
		WeaponAnimatorSelfTestReport report,
		bool condition,
		string message )
	{
		if ( condition )
			report.Passed++;
		else
			report.Failures.Add( message );
	}

	private static void Equal<T>(
		WeaponAnimatorSelfTestReport report,
		T expected,
		T actual,
		string message )
	{
		Check(
			report,
			EqualityComparer<T>.Default.Equals( expected, actual ),
			$"{message} Expected '{expected}', got '{actual}'." );
	}

	private static void Near(
		WeaponAnimatorSelfTestReport report,
		float expected,
		float actual,
		float tolerance,
		string message )
	{
		Check(
			report,
			MathF.Abs( expected - actual ) <= tolerance,
			$"{message} Expected {expected}, got {actual}." );
	}

	private static void Near(
		WeaponAnimatorSelfTestReport report,
		Vector3 expected,
		Vector3 actual,
		float tolerance,
		string message )
	{
		Check(
			report,
			expected.Distance( actual ) <= tolerance,
			$"{message} Expected {expected}, got {actual}." );
	}
}
sonac.sbox-animator / Editor/WeaponAnimatorWindow.cs
Editor library
#nullable enable annotations

using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

[EditorForAssetType( "wepanim" )]
public sealed class WeaponAnimatorWindow : DockWindow, IAssetEditor
{
	private readonly WeaponAnimatorController _controller = new();
	private readonly WeaponSourceImporter _importer = new();
	private readonly AssetGenerationService _generator = new();
	private bool _generating;
	private CancellationTokenSource? _generationCancellation;
	private bool _closeAfterGenerationStops;
	private bool _refreshingMaterials;
	private Asset? _asset;
	private WeaponAnimationAsset? _resource;
	private Widget? _root;
	private WeaponAnimatorToolbar? _toolbar;
	private WeaponAnimatorViewport? _viewport;
	private ValidationStatusPanel? _statusPanel;
	private Splitter? _horizontalSplitter;
	private Splitter? _verticalSplitter;
	private Splitter? _animationRightSplitter;
	private Splitter? _animationOuterSplitter;
	private Button? _validationButton;
	private Button? _generateButton;
	private Button? _playButton;
	private bool _allowClose;
	private bool _rebaseOnConfirm;
	private bool _rebuilding;
	private WeaponAnimationMigrationResult? _migration;
	private bool _migrationBackupRequired;
	private bool _recoveryWritePending;
	private bool _closing;
	private int _recoveryRequestVersion;

	public bool CanOpenMultipleAssets => false;
	public void SelectMember( string memberName ) { }

	public WeaponAnimatorWindow()
	{
		DeleteOnClose = true;
		WindowTitle = "S&box Weapon Animator";
		Title = WindowTitle;
		Size = new Vector2( 1600, 940 );
		MinimumSize = new Vector2( 1200, 720 );
		StateCookie = "SboxWeaponAnimator.Window";
		SetWindowIcon( "animation" );

		_controller.DocumentChanged += OnDocumentChanged;
		_controller.DirtyChanged += OnDirtyChanged;
		_controller.PlaybackChanged += RefreshToolbarState;

		BuildMenuBar();
		BuildWorkspace();
		Show();
	}

	public void AssetOpen( Asset asset )
	{
		_asset = asset;
		_resource = asset?.LoadResource<WeaponAnimationAsset>() ?? new WeaponAnimationAsset();
		var document = _resource.Document ?? WeaponAnimationDocument.CreateDefault();
		var adoptedName = AdoptAssetFileName( document, asset );
		_migration = MigrateAndRepair( document );
		var sourceRecovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
			document,
			out var sourceRecoveryMessage );
		_migrationBackupRequired = _migration.Changed;
		_controller.SetDocument( document );
		if ( _migration.Changed || sourceRecovered || adoptedName )
			_controller.ReplaceWithoutHistory( document, true );

		if ( document.ActiveStage == WeaponAnimatorStage.Animate
			&& document.Source.Compiled )
		{
			PreviewHostBuilder.Build( document );
		}

		BuildWorkspace();
		if ( !OfferRecovery() )
			OfferCachedImportRecovery();
		if ( sourceRecovered )
			_statusPanel?.SetMessage( sourceRecoveryMessage, ValidationSeverity.Warning );
		else if ( _migration.Changed )
			_statusPanel?.SetMessage( _migration.Summary, ValidationSeverity.Warning );
		RefreshTitle();
	}

	protected override bool OnClose()
	{
		SaveWorkspaceState();
		if ( _generating )
		{
			_closeAfterGenerationStops = true;
			_generationCancellation?.Cancel();
			_statusPanel?.SetMessage(
				"Cancelling asset generation before closing…",
				ValidationSeverity.Warning );
			return false;
		}
		if ( _allowClose || !_controller.IsDirty )
		{
			DestroyWorkspace();
			return true;
		}

		Dialog.AskConfirm(
			() =>
			{
				if ( Save() )
				CloseAfterPrompt();
			},
			() =>
			{
				Dialog.AskConfirm(
					// Discarding closes without saving, but the autosave snapshot is kept so the
					// work is still recoverable on the next open. Only Save clears it.
					() => CloseAfterPrompt( clearRecovery: false ),
					"Discard all unsaved changes to this Weapon Animation Project?",
					"Discard Changes",
					"Discard",
					"Cancel" );
			},
			"Save changes before closing this Weapon Animation Project?",
			"Unsaved Weapon Animation Project",
			"Save",
			"More Options" );
		return false;
	}

	[Shortcut( "editor.save", "Ctrl+S", ShortcutType.Window )]
	private void ShortcutSave() => Save();

	[Shortcut( "editor.undo", "Ctrl+Z", ShortcutType.Window )]
	private void ShortcutUndo() => _controller.Undo();

	[Shortcut( "editor.redo", "Ctrl+Y", ShortcutType.Window )]
	private void ShortcutRedo() => _controller.Redo();

	[Shortcut( "weaponanim.copykeys", "Ctrl+C", ShortcutType.Window )]
	private void ShortcutCopy() => _controller.CopySelectedKeys();

	[Shortcut( "weaponanim.pastekeys", "Ctrl+V", ShortcutType.Window )]
	private void ShortcutPaste() => _controller.PasteKeys();

	[Shortcut( "weaponanim.cutkeys", "Ctrl+X", ShortcutType.Window )]
	private void ShortcutCut() => _controller.CutSelectedKeys();

	[Shortcut( "weaponanim.key", "K", ShortcutType.Window )]
	private void ShortcutKey() => _controller.KeySelectedTransform();

	[Shortcut( "weaponanim.move", "W", ShortcutType.Window )]
	private void ShortcutMove()
	{
		if ( _viewport?.ConsumesFreeLookMovementShortcut == true )
			return;
		_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Move );
	}

	[Shortcut( "weaponanim.rotate", "E", ShortcutType.Window )]
	private void ShortcutRotate() =>
		_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Rotate );

	[Shortcut( "weaponanim.scale", "R", ShortcutType.Window )]
	private void ShortcutScale() =>
		_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Scale );

	[EditorEvent.Hotload]
	public void OnHotload()
	{
		HostSkeletonBuilder.ClearCache();
		SaveWorkspaceState();
		MenuBar.Clear();
		BuildMenuBar();
		var sourceRecovered = false;
		var sourceRecoveryMessage = "";
		_controller.Mutate(
			"Recover missing source preview",
			document => sourceRecovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
				document,
				out sourceRecoveryMessage ) );
		if ( _controller.Document.Source.Compiled )
			PreviewHostBuilder.Build( _controller.Document );
		BuildWorkspace();
		if ( sourceRecovered )
			_statusPanel?.SetMessage( sourceRecoveryMessage, ValidationSeverity.Warning );
	}

	private void BuildMenuBar()
	{
		var file = MenuBar.AddMenu( "File" );
		file.AddOption( "New", "note_add", WeaponAnimatorLauncher.CreateNew );
		file.AddOption( "Open…", "folder_open", WeaponAnimatorLauncher.OpenExisting );
		file.AddSeparator();
		file.AddOption( "Save", "save", () => Save(), "editor.save" );
		file.AddOption( "Save As…", "save_as", SaveAs );
		file.AddOption( "Generate Assets", "build", GenerateAssets );
		file.AddSeparator();
		file.AddOption( "Close", "close", Close );

		var edit = MenuBar.AddMenu( "Edit" );
		edit.AddOption( "Undo", "undo", _controller.Undo, "editor.undo" );
		edit.AddOption( "Redo", "redo", _controller.Redo, "editor.redo" );
		edit.AddSeparator();
		edit.AddOption( "Cut Keys", "content_cut", _controller.CutSelectedKeys );
		edit.AddOption( "Copy Keys", "content_copy", _controller.CopySelectedKeys );
		edit.AddOption( "Paste Keys", "content_paste", _controller.PasteKeys );
		edit.AddOption( "Delete Keys", "delete", _controller.DeleteSelectedKeys );
		edit.AddSeparator();
		edit.AddOption( "Preferences…", "tune", OpenPreferences );

		var view = MenuBar.AddMenu( "View" );
		view.AddOption( "Calibrate", "straighten", RequestCalibrationStage );
		view.AddOption( "Animate", "animation", () => SwitchStage( WeaponAnimatorStage.Animate ) );
		view.AddSeparator();
		var guides = view.AddOption( "Toggle Guides", "aspect_ratio", () =>
			_controller.Mutate( "Viewport guides", d => d.Workspace.ShowGuides = !d.Workspace.ShowGuides ) );
		BindCheckedState( guides, () => _controller.Document.Workspace.ShowGuides );
		var skeleton = view.AddOption( "Toggle Skeleton", "accessibility_new", () =>
			_controller.Mutate( "Skeleton overlay", d => d.Workspace.ShowSkeleton = !d.Workspace.ShowSkeleton ) );
		BindCheckedState( skeleton, () => _controller.Document.Workspace.ShowSkeleton );
		var xray = view.AddOption( "X-Ray Skeleton", "visibility", () =>
			_controller.UpdateWorkspacePreference(
				"X-ray skeleton",
				workspace => workspace.XRaySkeleton = !workspace.XRaySkeleton ) );
		BindCheckedState( xray, () => _controller.Document.Workspace.XRaySkeleton );
		var boneOcclusion = view.AddOption( "Bone Occlusion", "gradient", () =>
			_controller.UpdateWorkspacePreference(
				"Bone occlusion",
				workspace => workspace.BoneOcclusionEnabled =
					!workspace.BoneOcclusionEnabled ) );
		BindCheckedState(
			boneOcclusion,
			() => _controller.Document.Workspace.BoneOcclusionEnabled );
		var ikBones = view.AddOption( "Show IK Bones", "polyline", () =>
			_controller.UpdateWorkspacePreference(
				"Show IK bones",
				workspace => workspace.ShowIkBones = !workspace.ShowIkBones ) );
		BindCheckedState( ikBones, () => _controller.Document.Workspace.ShowIkBones );
		var onionSkins = view.AddOption( "Toggle Onion Skins", "filter_none", () =>
			_controller.Mutate( "Onion skins", d => d.Workspace.ShowOnionSkins = !d.Workspace.ShowOnionSkins ) );
		BindCheckedState(
			onionSkins,
			() => _controller.Document.Workspace.ShowOnionSkins );
		var cameraPreview = view.AddOption( "Viewmodel Camera Preview", "videocam", () =>
			_controller.Mutate(
				"Preview camera",
				d => d.Workspace.FirstPersonPreview = !d.Workspace.FirstPersonPreview ) );
		BindCheckedState(
			cameraPreview,
			() => _controller.Document.Workspace.FirstPersonPreview );
		view.AddSeparator();
		view.AddOption( "Reset Workspace", "restart_alt", ResetWorkspace );

		var tools = MenuBar.AddMenu( "Tools" );
		tools.AddOption( "Validate", "rule", Validate );
		tools.AddOption( "Rebuild Preview Rig", "refresh", RebuildPreviewHost );
		tools.AddOption( "Reimport Source", "published_with_changes", ReimportSource );
		tools.AddOption( "Refresh Materials", "texture", RefreshMaterials );
		tools.AddOption( "Open Generated Folder", "folder", OpenGeneratedFolder );
	}

	private static void BindCheckedState( Option option, Func<bool> fetch )
	{
		option.Checkable = true;
		option.Checked = fetch();
		option.FetchCheckedState = fetch;
	}

	private void BuildWorkspace()
	{
		if ( _rebuilding )
			return;
		_rebuilding = true;
		SaveWorkspaceState();
		DestroyWorkspace();

		_root = new Widget( this );
		_root.SetStyles( "background-color: rgb(13,15,17); border: none;" );
		_root.Layout = Layout.Column();
		_root.Layout.Margin = 0;
		_root.Layout.Spacing = 4;

		_toolbar = new WeaponAnimatorToolbar( _root );
		BuildToolbar();
		_root.Layout.Add( _toolbar );

		_viewport = new WeaponAnimatorViewport( _controller );
		_viewport.StatusChanged += ( message ) => _statusPanel?.SetMessage( message );
		_viewport.LegacyIdleRepaired += () => _migrationBackupRequired = true;

		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
			BuildCalibrationLayout();
		else
			BuildAnimationLayout();

		Canvas = _root;
		_rebuilding = false;
		RefreshToolbarState();
	}

	private void BuildToolbar()
	{
		if ( _toolbar is null )
			return;
		_toolbar.Clear();
		_toolbar.AddLeft( "Save", "save", () => Save() );
		_generateButton = _toolbar.AddLeft( "Generate", "build", GenerateAssets, true );
		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Animate )
		{
			_playButton = _toolbar.AddLeft( "Play", "play_arrow", TogglePlayback );
		}
		_toolbar.AddLeft( "Undo", "undo", _controller.Undo, overflowAtNarrowWidth: true );
		_toolbar.AddLeft( "Redo", "redo", _controller.Redo, overflowAtNarrowWidth: true );

		var calibrate = _toolbar.AddCenter(
			"1  Calibrate",
			"straighten",
			RequestCalibrationStage,
			_controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate );
		calibrate.IsToggle = true;
		calibrate.IsChecked = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate;
		var animate = _toolbar.AddCenter(
			"2  Animate",
			"animation",
			() => SwitchStage( WeaponAnimatorStage.Animate ),
			_controller.Document.ActiveStage == WeaponAnimatorStage.Animate );
		animate.IsToggle = true;
		animate.IsChecked = _controller.Document.ActiveStage == WeaponAnimatorStage.Animate;

		_validationButton = _toolbar.AddRight( "Validate", "rule", Validate );
		RefreshGenerationButton();
		_toolbar.BalanceCenter();
	}

	private void BuildCalibrationLayout()
	{
		if ( _root is null || _viewport is null )
			return;

		var rigPanel = new RigAuditPanel( _controller );
		rigPanel.ImportRequested += ImportSource;
		rigPanel.RigReviewConfirmed += RebuildPreviewHost;

		var inspector = new CalibrationInspectorPanel( _controller );
		inspector.PickRequested += _viewport.SetPickMode;
		inspector.AutoAlignRequested += AutoAlign;
		inspector.ConfirmRequested += ConfirmCalibration;
		inspector.RebuildPreviewRequested += RebuildPreviewHost;
		inspector.SetModelDimensions( _viewport.ModelDimensions );
		_viewport.ModelDimensionsChanged += inspector.SetModelDimensions;

		_statusPanel = new ValidationStatusPanel();
		var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
		_statusPanel.SetReport( report );

		var left = new PanelChrome( "RIG AUDIT", "account_tree", rigPanel );
		var center = new PanelChrome( "3D CALIBRATION", "view_in_ar", _viewport );
		var right = new PanelChrome( "CALIBRATION", "tune", inspector );
		var bottom = new PanelChrome( "VALIDATION + IMPORT", "fact_check", _statusPanel );
		left.MinimumSize = new Vector2( 260, 200 );
		left.MaximumSize = new Vector2( 520, 10000 );
		right.MinimumSize = new Vector2( 310, 200 );
		right.MaximumSize = new Vector2( 560, 10000 );
		center.MinimumSize = new Vector2( 420, 240 );
		bottom.MinimumSize = new Vector2( 200, 55 );
		bottom.MaximumSize = new Vector2( 10000, 190 );
		BuildSplitLayout( left, center, right, bottom, true );
	}

	private void BuildAnimationLayout()
	{
		if ( _root is null || _viewport is null )
			return;

		var rigBrowser = new RigBrowserPanel( _controller );
		var inspector = new SelectedControlInspectorPanel( _controller );
		var clips = new ClipRackPanel(
			_controller,
			showClipHeader: false );
		var timeline = new AnimationTimelinePanel( _controller );
		clips.StatusChanged += ( message, severity ) => _statusPanel?.SetMessage( message, severity );
		inspector.StatusChanged += ( message, severity ) => _statusPanel?.SetMessage( message, severity );
		_statusPanel = new ValidationStatusPanel();
		_statusPanel.SetReport( WeaponAnimationValidator.ValidateForGeneration( _controller.Document ) );

		var left = new PanelChrome( "RIG BROWSER", "account_tree", rigBrowser );
		var center = new PanelChrome( "3D ANIMATION", "view_in_ar", _viewport );
		var right = new PanelChrome( "SELECTED CONTROL", "tune", inspector );
		var clipRack = new PanelChrome( "CLIP RACK", "video_library", clips );
		var bottom = new PanelChrome( "DOPE SHEET · CURVES · TAGS", "timeline", timeline );
		left.MinimumSize = new Vector2( 330, 240 );
		left.MaximumSize = new Vector2( 540, 10000 );
		right.MinimumSize = new Vector2( 370, 240 );
		right.MaximumSize = new Vector2( 600, 10000 );
		clipRack.MinimumSize = new Vector2( 370, 260 );
		clipRack.MaximumSize = new Vector2( 600, 10000 );
		center.MinimumSize = new Vector2( 420, 260 );
		bottom.MinimumSize = new Vector2( 300, 220 );
		bottom.MaximumSize = new Vector2( 10000, 520 );
		BuildAnimationSplitLayout( left, center, right, clipRack, bottom );
	}

	private void BuildAnimationSplitLayout(
		Widget left,
		Widget center,
		Widget inspector,
		Widget clips,
		Widget timeline )
	{
		if ( _root is null )
			return;

		_verticalSplitter = new Splitter( _root )
		{
			IsVertical = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_horizontalSplitter = new Splitter( _root )
		{
			IsHorizontal = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_horizontalSplitter.AddWidget( left );
		_horizontalSplitter.AddWidget( center );
		_horizontalSplitter.SetStretch( 0, 0 );
		_horizontalSplitter.SetStretch( 1, 1 );
		_horizontalSplitter.SetCollapsible( 0, false );
		_horizontalSplitter.SetCollapsible( 1, false );

		_verticalSplitter.AddWidget( _horizontalSplitter );
		_verticalSplitter.AddWidget( timeline );
		_verticalSplitter.SetStretch( 0, 1 );
		_verticalSplitter.SetStretch( 1, 0 );
		_verticalSplitter.SetCollapsible( 0, false );
		_verticalSplitter.SetCollapsible( 1, false );

		_animationRightSplitter = new Splitter( _root )
		{
			IsVertical = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_animationRightSplitter.AddWidget( inspector );
		_animationRightSplitter.AddWidget( clips );
		_animationRightSplitter.SetStretch( 0, 1 );
		_animationRightSplitter.SetStretch( 1, 1 );
		_animationRightSplitter.SetCollapsible( 0, false );
		_animationRightSplitter.SetCollapsible( 1, false );

		_animationOuterSplitter = new Splitter( _root )
		{
			IsHorizontal = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_animationOuterSplitter.AddWidget( _verticalSplitter );
		_animationOuterSplitter.AddWidget( _animationRightSplitter );
		_animationOuterSplitter.SetStretch( 0, 1 );
		_animationOuterSplitter.SetStretch( 1, 0 );
		_animationOuterSplitter.SetCollapsible( 0, false );
		_animationOuterSplitter.SetCollapsible( 1, false );
		_root.Layout.Add( _animationOuterSplitter, 1 );

		var workspace = _controller.Document.Workspace;
		if ( !string.IsNullOrWhiteSpace( workspace.AnimationMainSplitterState ) )
			_horizontalSplitter.RestoreState( workspace.AnimationMainSplitterState );
		if ( !string.IsNullOrWhiteSpace( workspace.AnimationVerticalSplitterState ) )
			_verticalSplitter.RestoreState( workspace.AnimationVerticalSplitterState );
		if ( !string.IsNullOrWhiteSpace( workspace.AnimationRightSplitterState ) )
			_animationRightSplitter.RestoreState( workspace.AnimationRightSplitterState );
		if ( !string.IsNullOrWhiteSpace( workspace.AnimationOuterSplitterState ) )
			_animationOuterSplitter.RestoreState( workspace.AnimationOuterSplitterState );
	}

	private void BuildSplitLayout(
		Widget left,
		Widget center,
		Widget right,
		Widget bottom,
		bool calibration )
	{
		if ( _root is null )
			return;
		_horizontalSplitter = new Splitter( _root )
		{
			IsHorizontal = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_horizontalSplitter.AddWidget( left );
		_horizontalSplitter.AddWidget( center );
		_horizontalSplitter.AddWidget( right );
		_horizontalSplitter.SetStretch( 0, 0 );
		_horizontalSplitter.SetStretch( 1, 1 );
		_horizontalSplitter.SetStretch( 2, 0 );
		_horizontalSplitter.SetCollapsible( 0, false );
		_horizontalSplitter.SetCollapsible( 1, false );
		_horizontalSplitter.SetCollapsible( 2, false );

		_verticalSplitter = new Splitter( _root )
		{
			IsVertical = true,
			OpaqueResize = true,
			HandleWidth = 4
		};
		_verticalSplitter.AddWidget( _horizontalSplitter );
		_verticalSplitter.AddWidget( bottom );
		_verticalSplitter.SetStretch( 0, 1 );
		_verticalSplitter.SetStretch( 1, 0 );
		_verticalSplitter.SetCollapsible( 0, false );
		_verticalSplitter.SetCollapsible( 1, false );
		_root.Layout.Add( _verticalSplitter, 1 );

		var workspace = _controller.Document.Workspace;
		var horizontalState = calibration
			? workspace.CalibrationSplitterState
			: workspace.AnimationSplitterState;
		var verticalState = calibration
			? workspace.CalibrationVerticalSplitterState
			: workspace.AnimationVerticalSplitterState;
		if ( !string.IsNullOrWhiteSpace( horizontalState ) )
			_horizontalSplitter.RestoreState( horizontalState );
		if ( !string.IsNullOrWhiteSpace( verticalState ) )
			_verticalSplitter.RestoreState( verticalState );
	}

	private void SaveWorkspaceState()
	{
		if ( _horizontalSplitter is null || _verticalSplitter is null )
			return;
		var workspace = _controller.Document.Workspace;
		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
		{
			workspace.CalibrationSplitterState = _horizontalSplitter.SaveState();
			workspace.CalibrationVerticalSplitterState = _verticalSplitter.SaveState();
		}
		else
		{
			workspace.AnimationMainSplitterState = _horizontalSplitter.SaveState();
			workspace.AnimationVerticalSplitterState = _verticalSplitter.SaveState();
			if ( _animationRightSplitter is not null )
				workspace.AnimationRightSplitterState = _animationRightSplitter.SaveState();
			if ( _animationOuterSplitter is not null )
				workspace.AnimationOuterSplitterState = _animationOuterSplitter.SaveState();
		}
	}

	private void DestroyWorkspace()
	{
		// Destroy the private scene synchronously before replacing its widget tree.
		_viewport?.ReleasePreviewScene();
		if ( _root.IsValid() )
			_root!.Destroy();
		_root = null;
		_toolbar = null;
		_viewport = null;
		_statusPanel = null;
		_horizontalSplitter = null;
		_verticalSplitter = null;
		_animationRightSplitter = null;
		_animationOuterSplitter = null;
		_validationButton = null;
		_playButton = null;
	}

	private void ImportSource()
	{
		var dialog = new FileDialog( this )
		{
			Title = "Import Rigged Weapon",
			Directory = global::Editor.FileSystem.Content.GetFullPath( "/" )
		};
		dialog.SetModeOpen();
		dialog.SetFindExistingFile();
		dialog.SetNameFilter( "Rigged Models (*.fbx *.smd *.dmx *.vmdl)" );
		if ( !dialog.Execute() )
			return;

		SourceImportResult? import = null;
		PreviewHostResult? host = null;
		_controller.Mutate( "Import source weapon", document =>
		{
			import = _importer.Import( document, dialog.SelectedFile );
			if ( import.Success )
				host = PreviewHostBuilder.Build( document );
		} );

		_statusPanel?.SetMessage(
			$"{import?.Message} {host?.Message}",
			import?.Success == true && host?.Success == true
				? ValidationSeverity.Info
				: ValidationSeverity.Error );
		_viewport?.RebuildPreview();
	}

	private void ReimportSource()
	{
		var source = string.IsNullOrWhiteSpace( _controller.Document.Source.OriginalSourcePath )
			? _controller.Document.Source.SourcePath
			: _controller.Document.Source.OriginalSourcePath;
		if ( string.IsNullOrWhiteSpace( source ) )
		{
			ImportSource();
			return;
		}

		SourceImportResult? result = null;
		_controller.Mutate( "Reimport source weapon", document =>
		{
			result = _importer.Import( document, source );
			if ( result.Success )
				PreviewHostBuilder.Build( document );
		} );
		_statusPanel?.SetMessage(
			result?.Message ?? "Reimport failed.",
			result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
		_viewport?.RebuildPreview();
	}

	private async void RefreshMaterials()
	{
		if ( _refreshingMaterials || _generating )
		{
			_statusPanel?.SetMessage(
				"Material refresh or generation is already in progress.",
				ValidationSeverity.Warning );
			return;
		}

		_refreshingMaterials = true;
		Log.Info( "[Weapon Animator] manual material refresh requested." );
		try
		{
			_statusPanel?.SetMessage(
				"Discovering and compiling source materials…",
				ValidationSeverity.Info );
			var result = await RefreshMaterialsCoreAsync( "Refresh source materials" );
			_statusPanel?.SetMessage(
				result.Message,
				result.Success
					? ValidationSeverity.Info
					: ValidationSeverity.Error );
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] material refresh threw: {ex}" );
			_statusPanel?.SetMessage(
				$"Material refresh failed: {ex.Message}",
				ValidationSeverity.Error );
		}
		finally
		{
			_refreshingMaterials = false;
			RefreshToolbarState();
		}
	}

	private async System.Threading.Tasks.Task<SourceImportResult> RefreshMaterialsCoreAsync(
		string historyDescription )
	{
		var recovered = false;
		var recoveryMessage = "";
		_controller.Mutate(
			"Recover missing source preview",
			document => recovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(
				document,
				out recoveryMessage ) );
		if ( recovered )
		{
			_viewport?.RebuildPreview();
			_statusPanel?.SetMessage( recoveryMessage, ValidationSeverity.Warning );
		}

		var documentId = _controller.Document.DocumentId;
		var sourceHash = _controller.Document.Source.SourceHash;
		var result = await _importer.RefreshMaterialsAsync( _controller.Document );
		if ( !result.Success )
			return result;

		if ( _controller.Document.DocumentId != documentId
			|| !string.Equals(
				_controller.Document.Source.SourceHash,
				sourceHash,
				StringComparison.OrdinalIgnoreCase ) )
		{
			return new SourceImportResult
			{
				Success = false,
				Message = "The open document or source changed while materials were compiling; "
					+ "the candidate preview was not applied."
			};
		}

		_controller.Mutate(
			historyDescription,
			document => WeaponSourceImporter.ApplyMaterialRefresh( document, result ) );
		_viewport?.RebuildPreview();
		WeaponSourceImporter.CleanupLegacyMaterialPreview( _controller.Document );
		return result;
	}

	private void AutoAlign()
	{
		var document = _controller.Document;
		var grip = document.Calibration.GetAnchor( AnchorKind.Grip );
		var rear = document.Calibration.GetAnchor( AnchorKind.RearBore );
		var front = document.Calibration.GetAnchor( AnchorKind.FrontBore );
		if ( grip is null || rear is null || front is null )
		{
			_statusPanel?.SetMessage(
				"Set the primary grip and both optional alignment markers before running Auto-align.",
				ValidationSeverity.Error );
			return;
		}

		if ( !WeaponAnimationMath.TryCalculateAlignment(
			grip.LocalPosition,
			rear.LocalPosition,
			front.LocalPosition,
			document.Calibration.UpAxis,
			document.Calibration.UniformScale,
			new Vector3( 12, -3, -2 ),
			out var alignment ) )
		{
			_statusPanel?.SetMessage( "The selected anchors cannot produce a finite alignment.", ValidationSeverity.Error );
			return;
		}

		_controller.Mutate( "Auto-align weapon", d =>
		{
			d.Calibration.PhysicalTransform = alignment.PhysicalTransform;
			var rearWorld = alignment.PhysicalTransform.PointToWorld( rear.LocalPosition );
			var correctionWorld = new Vector3( 0, -rearWorld.y, -rearWorld.z );
			var correctionLocal = alignment.PhysicalTransform.PointToLocal(
				alignment.PhysicalTransform.Position + correctionWorld );
			d.Calibration.FramingTransform = d.Calibration.FramingTransform.WithPosition( correctionLocal );
			d.Calibration.Confirmed = false;
		} );

		_statusPanel?.SetMessage(
			alignment.BoreMayBeReversed
				? "Aligned, but the bore points appear reversed. Swap rear and front if the muzzle faces away from +X."
				: "Grip placed at the canonical hand origin; bore aligned to +X and projected through the crosshair.",
			alignment.BoreMayBeReversed ? ValidationSeverity.Warning : ValidationSeverity.Info );
	}

	private void ConfirmCalibration()
	{
		PreviewHostResult? hostResult = null;
		_controller.Mutate( "Build calibrated preview host", document =>
			hostResult = PreviewHostBuilder.Build( document ) );
		var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
		if ( !report.IsValid || hostResult?.Success != true )
		{
			_statusPanel?.SetReport( report, hostResult?.Message ?? "" );
			return;
		}

		var previous = _controller.Document.Calibration.Snapshot;
		_controller.Mutate( "Confirm calibration", document =>
		{
			if ( _rebaseOnConfirm && previous is not null )
				CalibrationRebaser.RebaseAnimationData( document, previous );

			var calibration = document.Calibration;
			calibration.Revision++;
			calibration.Confirmed = true;
			calibration.Snapshot = new CalibrationSnapshot
			{
				Revision = calibration.Revision,
				SourceHash = document.Source.SourceHash,
				RigHash = document.Rig.ProfileHash,
				UniformScale = calibration.UniformScale,
				PhysicalTransform = calibration.PhysicalTransform,
				FramingTransform = calibration.FramingTransform,
				Anchors = Json.Deserialize<System.Collections.Generic.List<WeaponAnchor>>(
					Json.Serialize( calibration.Anchors ) ) ?? [],
				ConfirmedUtc = DateTime.UtcNow
			};
			if ( previous is null )
				CalibrationBindingSeeder.SeedDefaultPrimaryHand( document );

			var idle = document.EnsureClip( WeaponClipRole.Idle );
			if ( idle.Tracks.Count == 0 || idle.IsBindPoseSeed )
			{
				var skeleton = HostSkeletonBuilder.BuildCached( document );
				IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
			}
			idle.Readiness = ClipReadiness.Ready;
			document.Workspace.SelectedClipId = idle.Id;
			document.ActiveStage = WeaponAnimatorStage.Animate;
		} );
		_rebaseOnConfirm = false;
		BuildWorkspace();
	}

	private void RequestCalibrationStage()
	{
		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
			return;
		var hasAnimation = _controller.Document.Clips.Any( x =>
			x.Tracks.Count > 0 && x.Role != WeaponClipRole.Idle );
		if ( !hasAnimation )
		{
			SwitchStage( WeaponAnimatorStage.Calibrate );
			return;
		}

		Dialog.AskConfirm(
			() =>
			{
				_rebaseOnConfirm = true;
				SwitchStage( WeaponAnimatorStage.Calibrate );
			},
			() =>
			{
				Dialog.AskConfirm(
					() =>
					{
						_controller.Mutate(
							"Discard animation for recalibration",
							CalibrationRebaser.DiscardAnimationData );
						_rebaseOnConfirm = false;
						SwitchStage( WeaponAnimatorStage.Calibrate );
					},
					"Discard all authored animation and binding data before recalibrating?",
					"Discard Animation Data",
					"Discard",
					"Cancel" );
			},
			"Rebase bindings, controls, and animation roots onto the new calibration when it is confirmed?",
			"Return to Calibration",
			"Rebase",
			"Other Options" );
	}

	private void SwitchStage( WeaponAnimatorStage stage )
	{
		if ( stage == _controller.Document.ActiveStage )
			return;
		if ( stage == WeaponAnimatorStage.Animate )
		{
			var report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );
			if ( !_controller.Document.Calibration.Confirmed || !report.IsValid )
			{
				_statusPanel?.SetMessage(
					"Confirm a valid calibration before entering Animate.",
					ValidationSeverity.Error );
				return;
			}
		}

		SaveWorkspaceState();
		_controller.Mutate( $"Switch to {stage}", d => d.ActiveStage = stage );
		BuildWorkspace();
	}

	private bool Save()
	{
		if ( _asset is null || _resource is null )
		{
			SaveAs();
			return _asset is not null;
		}

		SaveWorkspaceState();
		if ( _migrationBackupRequired )
		{
			try
			{
				WeaponAnimationMigration.CreateBackup(
					_asset.AbsolutePath,
					_migration?.SourceSchemaVersion ?? 2 );
			}
			catch ( Exception ex )
			{
				_statusPanel?.SetMessage(
					$"Migration backup failed; the project was not saved: {ex.Message}",
					ValidationSeverity.Error );
				return false;
			}
		}

		_resource.Document = _controller.Document;
		if ( !_asset.SaveToDisk( _resource ) )
		{
			_statusPanel?.SetMessage( "The .wepanim asset could not be saved.", ValidationSeverity.Error );
			return false;
		}

		_controller.MarkSaved();
		_migrationBackupRequired = false;
		RecoveryService.Clear( _controller.Document.DocumentId );
		_statusPanel?.SetMessage( $"Saved {_asset.Path}." );
		RefreshTitle();
		return true;
	}

	private void SaveAs()
	{
		var dialog = new FileDialog( this )
		{
			Title = "Save Weapon Animation Project As",
			Directory = global::Editor.FileSystem.Content.GetFullPath( "/" ),
			DefaultSuffix = "wepanim"
		};
		dialog.SetModeSave();
		dialog.SetFindFile();
		dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
		if ( !dialog.Execute() )
			return;

		var path = Path.ChangeExtension( dialog.SelectedFile, ".wepanim" );
		var asset = AssetSystem.CreateResource( "wepanim", path );
		if ( asset is null )
		{
			_statusPanel?.SetMessage( "Could not create the new .wepanim asset.", ValidationSeverity.Error );
			return;
		}

		_asset = asset;
		// Saving under a new filename renames the project, so the generated output follows it.
		AdoptAssetFileName( _controller.Document, _asset );
		_resource = new WeaponAnimationAsset { Document = _controller.Document };
		if ( !_asset.SaveToDisk( _resource ) )
		{
			_statusPanel?.SetMessage( "Save As failed.", ValidationSeverity.Error );
			return;
		}
		_controller.MarkSaved();
		_migrationBackupRequired = false;
		RecoveryService.Clear( _controller.Document.DocumentId );
		RefreshTitle();
	}

	private async void GenerateAssets()
	{
		// Compiling waits on the asset system across frames, so keep a second press from
		// starting a competing run over the same output files.
		if ( _generating )
		{
			_generationCancellation?.Cancel();
			_statusPanel?.SetMessage(
				"Cancelling asset generation safely…",
				ValidationSeverity.Warning );
			return;
		}

		_generating = true;
		_generationCancellation = new CancellationTokenSource();
		RefreshGenerationButton();
		Log.Info( "[Weapon Animator] asset generation requested." );
		try
		{
			if ( WeaponMaterialPipeline.RequiresPreviewRefresh( _controller.Document ) )
			{
				var materialImport = await RefreshMaterialsCoreAsync(
					"Discover source materials" );
				if ( !materialImport.Success )
				{
					_statusPanel?.SetMessage(
						materialImport.Message,
						ValidationSeverity.Error );
					return;
				}
			}

			_statusPanel?.SetMessage( "Generating and compiling assets…", ValidationSeverity.Info );
			var result = await _generator.GenerateAsync(
				_controller.Document,
				progress =>
				{
					var count = progress.Total > 0
						? $" {progress.Completed}/{progress.Total}"
						: "";
					_statusPanel?.SetMessage(
						$"{progress.Stage}{count} — {progress.Detail}",
						ValidationSeverity.Info );
				},
				_generationCancellation.Token );
			if ( result.Success )
			{
				_statusPanel?.SetMessage(
					$"Generated and reloaded {result.GeneratedFiles.Count} files in {result.OutputFolder}.",
					ValidationSeverity.Info );
				Save();
			}
			else if ( result.Cancelled )
			{
				_statusPanel?.SetMessage(
					"Asset generation cancelled; previous owned outputs were restored.",
					ValidationSeverity.Warning );
			}
			else
			{
				var message = string.Join(
					"  ·  ",
					result.Diagnostics.Where( x => x.Severity == ValidationSeverity.Error )
						.Select( x => x.Message )
						.Take( 4 ) );
				_statusPanel?.SetMessage(
					string.IsNullOrWhiteSpace( message ) ? "Generation failed validation." : message,
					ValidationSeverity.Error );
			}
		}
		catch ( OperationCanceledException )
		{
			_statusPanel?.SetMessage(
				"Asset generation cancelled before outputs were changed.",
				ValidationSeverity.Warning );
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] generation threw: {ex}" );
			_statusPanel?.SetMessage( $"Generation failed: {ex.Message}", ValidationSeverity.Error );
		}
		finally
		{
			_generating = false;
			_generationCancellation?.Dispose();
			_generationCancellation = null;
			RefreshGenerationButton();
			RefreshToolbarState();
			if ( _closeAfterGenerationStops )
			{
				_closeAfterGenerationStops = false;
				Close();
			}
		}
	}

	private void RefreshGenerationButton()
	{
		if ( _generateButton is null )
			return;

		_generateButton.Text = _generating ? "Cancel" : "Generate";
		_generateButton.Icon = _generating ? "stop" : "build";
		_generateButton.Tint = _generating
			? WeaponAnimatorTheme.Coral * 0.58f
			: WeaponAnimatorTheme.Cyan * 0.72f;
		if ( _generateButton is WeaponAnimatorButton button )
			button.FitToContent( true );
		_toolbar?.BalanceCenter();
	}

	private void Validate()
	{
		var report = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate
			? WeaponAnimationValidator.ValidateCalibration( _controller.Document )
			: WeaponAnimationValidator.ValidateForGeneration( _controller.Document );
		_statusPanel?.SetReport( report );
		RefreshToolbarState();
	}

	private void RebuildPreviewHost()
	{
		PreviewHostResult? result = null;
		_controller.Mutate( "Rebuild preview host", document =>
			result = PreviewHostBuilder.Build( document ) );
		_statusPanel?.SetMessage(
			result?.Message ?? "Preview host rebuild failed.",
			result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
		_viewport?.RebuildPreview();
	}

	private void OpenGeneratedFolder()
	{
		try
		{
			var path = AssetGenerationService.GetOutputFolder( _controller.Document );
			if ( Directory.Exists( path ) )
				EditorUtility.OpenFolder( path );
			else
				_statusPanel?.SetMessage( "Generate assets before opening the output folder.", ValidationSeverity.Warning );
		}
		catch ( Exception ex )
		{
			_statusPanel?.SetMessage(
				$"Could not resolve the output folder: {ex.Message}",
				ValidationSeverity.Error );
		}
	}

	private void TogglePlayback()
	{
		_controller.TogglePlayback();
	}

	private void ResetWorkspace()
	{
		_controller.Mutate( "Reset workspace", document =>
		{
			var state = document.Workspace;
			state.CameraFocus = Vector3.Zero;
			state.CameraAngles = new Angles( 12, 180, 0 );
			state.CameraDistance = 48;
			state.FreeLookCamera = false;
			state.CameraPosition = Vector3.Zero;
			state.CameraMoveSpeed = 1;
			state.FullBrightViewport = false;
			state.CalibrationSplitterState = "";
			state.CalibrationVerticalSplitterState = "";
			state.AnimationSplitterState = "";
			state.AnimationVerticalSplitterState = "";
			state.AnimationTimelineSplitterState = "";
			state.AnimationRightSplitterState = "";
			state.AnimationMainSplitterState = "";
			state.AnimationOuterSplitterState = "";
			state.TimelineViews.Clear();
			state.CurveViews.Clear();
		} );
		BuildWorkspace();
		_viewport?.FitCamera();
	}

	private void OpenPreferences()
	{
		new WeaponAnimatorPreferencesWindow( _controller ).Show();
	}

	private void OnDocumentChanged()
	{
		if ( _controller.IsDirty )
			QueueRecoveryWrite();
		RefreshTitle();
		RefreshToolbarState();
	}

	private void OnDirtyChanged()
	{
		if ( _controller.IsDirty )
			QueueRecoveryWrite();
		RefreshTitle();
	}

	private void QueueRecoveryWrite()
	{
		if ( _closing || !_controller.IsDirty )
			return;

		_recoveryRequestVersion++;
		if ( _recoveryWritePending )
			return;

		_recoveryWritePending = true;
		_ = WriteRecoveryAfterQuietPeriodAsync();
	}

	private async Task WriteRecoveryAfterQuietPeriodAsync()
	{
		try
		{
			while ( !_closing && _controller.IsDirty )
			{
				var requestedVersion = _recoveryRequestVersion;
				await Task.Delay( 750 );
				if ( requestedVersion != _recoveryRequestVersion )
					continue;

				// Re-check after the wait. Saving inside the quiet period clears the recovery
				// file, and writing it back would make the next open offer to restore a snapshot
				// of an already-saved project.
				if ( _closing || !_controller.IsDirty )
					return;

				// Serialize on the main thread: the continuation above can resume on a worker,
				// and the document may be mutated while it is being written.
				await GameTask.MainThread();
				if ( !_closing && _controller.IsDirty )
					RecoveryService.Write( _controller.Document );
				return;
			}
		}
		finally
		{
			_recoveryWritePending = false;
		}
	}

	/// <summary>
	/// The .wepanim filename is the project's identity: generated folders and asset names follow it.
	/// This has to run on every open rather than only for new documents, because
	/// <c>WeaponAnimationAsset.Document</c> is initialised with <c>CreateDefault()</c> — it is never
	/// null, so an asset created outside the New Project flow always arrives carrying the
	/// "New Weapon" default and would otherwise generate into <c>weapons/new_weapon</c>.
	/// </summary>
	internal static bool AdoptAssetFileName( WeaponAnimationDocument document, Asset? asset ) =>
		AdoptAssetFileName( document, asset?.Path );

	internal static bool AdoptAssetFileName( WeaponAnimationDocument document, string? assetPath )
	{
		if ( string.IsNullOrWhiteSpace( assetPath ) )
			return false;

		var fileName = Path.GetFileNameWithoutExtension( assetPath.Replace( '\\', '/' ) );
		var slug = WeaponAnimationDocument.Slugify( fileName );
		if ( string.IsNullOrWhiteSpace( slug ) )
			return false;

		var changed = false;
		if ( document.Name != fileName )
		{
			document.Name = fileName;
			changed = true;
		}

		document.Output ??= new OutputSettings();
		if ( document.Output.AssetName != slug )
		{
			document.Output.AssetName = slug;
			changed = true;
		}
		return changed;
	}

	private void RefreshTitle()
	{
		WindowTitle = ComposeWindowTitle(
			_asset?.Path ?? "",
			_controller.Document.Name,
			_controller.IsDirty );
		Title = WindowTitle;
	}

	internal static string ComposeWindowTitle(
		string assetPath,
		string documentName,
		bool dirty )
	{
		var fileName = string.IsNullOrWhiteSpace( assetPath )
			? documentName
			: Path.GetFileName( assetPath.Replace( '\\', '/' ) );
		if ( string.IsNullOrWhiteSpace( fileName ) )
			fileName = "New Weapon";
		return $"S&box Weapon Animator — {fileName}{(dirty ? " *" : "")}";
	}

	private void RefreshToolbarState()
	{
		if ( _validationButton is null )
			return;
		var report = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate
			? WeaponAnimationValidator.ValidateCalibration( _controller.Document )
			: WeaponAnimationValidator.ValidateForGeneration( _controller.Document );
		_validationButton.Text = report.IsValid
			? report.WarningCount > 0 ? $"{report.WarningCount} warnings" : "Valid"
			: $"{report.ErrorCount} errors";
		if ( _validationButton is WeaponAnimatorButton validationButton )
			validationButton.FitToContent( true );
		_validationButton.Icon = report.IsValid
			? report.WarningCount > 0 ? "warning" : "check_circle"
			: "error";
		_validationButton.Tint = report.IsValid
			? report.WarningCount > 0 ? WeaponAnimatorTheme.Amber * 0.45f : WeaponAnimatorTheme.Green * 0.45f
			: WeaponAnimatorTheme.Coral * 0.5f;
		if ( _playButton is not null )
		{
			_playButton.Text = _controller.IsPlaying ? "Pause" : "Play";
			_playButton.Icon = _controller.IsPlaying ? "pause" : "play_arrow";
			if ( _playButton is WeaponAnimatorButton playButton )
				playButton.FitToContent( true );
		}
		_toolbar?.BalanceCenter();
	}

	private bool OfferRecovery()
	{
		if ( _asset is null )
			return false;
		var writeUtc = File.Exists( _asset.AbsolutePath )
			? File.GetLastWriteTimeUtc( _asset.AbsolutePath )
			: DateTime.MinValue;
		var recovery = RecoveryService.ReadNewerThan( _controller.Document.DocumentId, writeUtc );
		if ( recovery is null )
			return false;

		Dialog.AskConfirm(
			() =>
			{
				var migration = MigrateAndRepair( recovery );
				if ( migration.Migrated )
				{
					_migration = migration;
					_migrationBackupRequired = true;
				}
				NormalizeRecoveredSource( recovery );
				if ( recovery.Source.Compiled )
					PreviewHostBuilder.Build( recovery );
				_controller.ReplaceWithoutHistory( recovery, true );
				BuildWorkspace();
				var message = migration.Migrated
					? $"Recovered the newer autosave snapshot. {migration.Summary}"
					: "Recovered the newer autosave snapshot.";
				_statusPanel?.SetMessage( message, ValidationSeverity.Warning );
			},
			() => RecoveryService.Clear( _controller.Document.DocumentId ),
			"A newer recovery snapshot exists for this project. Restore it?",
			"Recover Weapon Animation Project",
			"Restore",
			"Discard Recovery" );
		return true;
	}

	private void OfferCachedImportRecovery()
	{
		var document = _controller.Document;
		if ( !string.IsNullOrWhiteSpace( document.Source.SourcePath ) )
			return;

		var cachedSource = FindCachedSource( document.DocumentId );
		if ( string.IsNullOrWhiteSpace( cachedSource ) )
			return;

		Dialog.AskConfirm(
			() =>
			{
				var result = _importer.Import( document, cachedSource );
				var host = result.Success ? PreviewHostBuilder.Build( document ) : null;
				_controller.ReplaceWithoutHistory( document, true );
				BuildWorkspace();
				_statusPanel?.SetMessage(
					$"{result.Message} {host?.Message}",
					result.Success && host?.Success == true
						? ValidationSeverity.Info
						: ValidationSeverity.Error );
			},
			() => { },
			"The saved document is empty, but a previous weapon import remains in its private cache. Recover that import?",
			"Recover Cached Weapon Import",
			"Recover Import",
			"Ignore Cache" );
	}

	private static string FindCachedSource( Guid documentId )
	{
		var cache = WeaponSourceImporter.GetPreviewCacheRoot( documentId );
		if ( !Directory.Exists( cache ) )
			return "";

		var wrapper = Directory.EnumerateFiles( cache, "source_*.vmdl" )
			.OrderByDescending( File.GetLastWriteTimeUtc )
			.FirstOrDefault();
		if ( string.IsNullOrWhiteSpace( wrapper ) )
			return "";

		var match = Regex.Match(
			File.ReadAllText( wrapper ),
			"filename\\s*=\\s*\"(?<path>[^\"]+\\.(?:fbx|smd|dmx|vmdl))\"",
			RegexOptions.IgnoreCase );
		if ( !match.Success )
			return "";

		var relative = match.Groups["path"].Value;
		var absolute = global::Editor.FileSystem.Content.GetFullPath( relative );
		if ( File.Exists( absolute ) )
			return absolute;

		var directory = Path.GetDirectoryName( absolute );
		var filename = Path.GetFileName( absolute );
		if ( string.IsNullOrWhiteSpace( directory ) || !Directory.Exists( directory ) )
			return "";

		return Directory.EnumerateFiles( directory )
			.FirstOrDefault( path =>
				Path.GetFileName( path ).Equals( filename, StringComparison.OrdinalIgnoreCase ) )
			?? "";
	}

	private void NormalizeRecoveredSource( WeaponAnimationDocument document )
	{
		if ( !document.Source.Compiled
			|| !document.Source.NeedsModelDocWrapper
			|| string.IsNullOrWhiteSpace( document.Rig.RootBone )
			|| document.Rig.RootBone.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase )
			|| !string.IsNullOrWhiteSpace( document.Source.SourceRootBoneName ) )
			return;

		var source = string.IsNullOrWhiteSpace( document.Source.OriginalSourcePath )
			? document.Source.SourcePath
			: document.Source.OriginalSourcePath;
		_importer.Import( document, source );
	}

	private void CloseAfterPrompt( bool clearRecovery = true )
	{
		_closing = true;
		_recoveryRequestVersion++;
		if ( clearRecovery )
			RecoveryService.Clear( _controller.Document.DocumentId );
		_allowClose = true;
		Close();
	}

	private static WeaponAnimationMigrationResult MigrateAndRepair(
		WeaponAnimationDocument document ) =>
		WeaponAnimationMigration.MigrateAndRepair( document );
}

public static class WeaponAnimatorLauncher
{
	[Menu( "Editor", "Tools/Weapon Animator/Open Weapon Animator", "animation", Priority = 0 )]
	public static void OpenPicker()
	{
		new WeaponAnimatorPickerWindow().Show();
	}

	public static void CreateNew()
	{
		var dialog = new FileDialog( null )
		{
			Title = "Create Weapon Animation Project",
			Directory = global::Editor.FileSystem.Content.GetFullPath( "/" ),
			DefaultSuffix = "wepanim"
		};
		dialog.SetModeSave();
		dialog.SetFindFile();
		dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
		if ( !dialog.Execute() )
			return;

		var path = Path.ChangeExtension( dialog.SelectedFile, ".wepanim" );
		var asset = AssetSystem.CreateResource( "wepanim", path );
		if ( asset is null )
			return;
		var resource = new WeaponAnimationAsset
		{
			Document = WeaponAnimationDocument.CreateDefault( Path.GetFileNameWithoutExtension( path ) )
		};
		asset.SaveToDisk( resource );
		IAssetEditor.OpenInEditor( asset, out _ );
	}

	public static void OpenExisting()
	{
		var dialog = new FileDialog( null )
		{
			Title = "Open Weapon Animation Project",
			Directory = global::Editor.FileSystem.Content.GetFullPath( "/" )
		};
		dialog.SetModeOpen();
		dialog.SetFindExistingFile();
		dialog.SetNameFilter( "Weapon Animation Project (*.wepanim)" );
		if ( !dialog.Execute() )
			return;

		var asset = AssetSystem.FindByPath( dialog.SelectedFile )
			?? AssetSystem.RegisterFile( dialog.SelectedFile );
		if ( asset is not null )
			IAssetEditor.OpenInEditor( asset, out _ );
	}
}

internal sealed class WeaponAnimatorPickerWindow : Window
{
	public WeaponAnimatorPickerWindow()
	{
		DeleteOnClose = true;
		WindowTitle = "Weapon Animator";
		Title = WindowTitle;
		Size = new Vector2( 520, 260 );
		SetWindowIcon( "animation" );

		var root = new Widget( this );
		root.SetStyles( "background-color: rgb(13,15,17);" );
		root.Layout = Layout.Column();
		root.Layout.Margin = new Sandbox.UI.Margin( 28 );
		root.Layout.Spacing = 14;
		var title = WeaponAnimatorTheme.Label( "WEAPON ANIMATOR", root );
		title.SetStyles(
			"background-color: transparent; border: none; padding: 0px;" +
			$"font-size: 18px; font-weight: 600; letter-spacing: 1.2px; color: {WeaponAnimatorTheme.Text.Hex};" );
		root.Layout.Add( title );
		var description = WeaponAnimatorTheme.Label(
			"Open a document-driven import, calibration, binding, and animation workspace. No active scene or selected GameObject is required.",
			root,
			true );
		description.WordWrap = true;
		root.Layout.Add( description );
		var row = RigAuditPanel.Row( root );
		row.Layout.Add( WeaponAnimatorTheme.Button(
			"New project",
			"note_add",
			() =>
			{
				Close();
				WeaponAnimatorLauncher.CreateNew();
			},
			row,
			true ), 1 );
		row.Layout.Add( WeaponAnimatorTheme.Button(
			"Open existing",
			"folder_open",
			() =>
			{
				Close();
				WeaponAnimatorLauncher.OpenExisting();
			},
			row ), 1 );
		root.Layout.Add( row );
		root.Layout.AddStretchCell();
		Canvas = root;
	}
}

internal sealed class WeaponAnimatorPreferencesWindow : Window
{
	public WeaponAnimatorPreferencesWindow( WeaponAnimatorController controller )
	{
		DeleteOnClose = true;
		WindowTitle = "Weapon Animator Preferences";
		Title = WindowTitle;
		Size = new Vector2( 420, 520 );
		var root = new Widget( this );
		root.SetStyles( "background-color: rgb(13,15,17);" );
		root.Layout = Layout.Column();
		root.Layout.Margin = new Sandbox.UI.Margin( 18 );
		root.Layout.Spacing = 8;
		root.Layout.Add( Toggle(
			"Auto-key transformed controls",
			controller.Document.Workspace.AutoKey,
			value => controller.Mutate( "Auto-key preference", d => d.Workspace.AutoKey = value ) ) );
		root.Layout.Add( Toggle(
			"Use local gizmo space",
			controller.Document.Workspace.LocalGizmos,
			value => controller.Mutate( "Gizmo preference", d => d.Workspace.LocalGizmos = value ) ) );
		root.Layout.Add( Toggle(
			"Snap position",
			controller.Document.Workspace.SnapPosition,
			value => controller.Mutate( "Position snapping", d => d.Workspace.SnapPosition = value ) ) );
		root.Layout.Add( Toggle(
			"Snap rotation",
			controller.Document.Workspace.SnapRotation,
			value => controller.Mutate( "Rotation snapping", d => d.Workspace.SnapRotation = value ) ) );
		root.Layout.Add( Number(
			"Rotation snap angle",
			controller.Document.Workspace.RotationSnapDegrees,
			0.25f,
			180,
			value => controller.UpdateWorkspacePreference(
				"Rotation snap angle",
				workspace => workspace.RotationSnapDegrees = value ) ) );
		root.Layout.Add( WeaponAnimatorTheme.SectionLabel(
			"VIEWPORT GRID",
			root,
			topMargin: true ) );
		root.Layout.Add( Number(
			"Grid opacity",
			controller.Document.Workspace.GridOpacity,
			0,
			0.5f,
			value => controller.UpdateWorkspacePreference(
				"Grid opacity",
				workspace => workspace.GridOpacity = value ) ) );
		root.Layout.Add( Number(
			"Grid line weight",
			controller.Document.Workspace.GridLineThickness,
			0.1f,
			2,
			value => controller.UpdateWorkspacePreference(
				"Grid line weight",
				workspace => workspace.GridLineThickness = value ) ) );
		root.Layout.Add( WeaponAnimatorTheme.SectionLabel(
			"VIEWPORT LIGHTING",
			root,
			topMargin: true ) );
		root.Layout.Add( Toggle(
			"Cyan edge light",
			controller.Document.Workspace.RimLightEnabled,
			value => controller.UpdateWorkspacePreference(
				"Cyan edge light",
				workspace => workspace.RimLightEnabled = value ) ) );
		root.Layout.Add( Number(
			"Cyan edge brightness",
			controller.Document.Workspace.RimLightIntensity,
			0,
			12,
			value => controller.UpdateWorkspacePreference(
				"Cyan edge brightness",
				workspace => workspace.RimLightIntensity = value ) ) );
		var lightingNote = WeaponAnimatorTheme.Label(
			"The edge light is disabled automatically in Full Bright.",
			root,
			true );
		lightingNote.WordWrap = true;
		root.Layout.Add( lightingNote );
		root.Layout.AddStretchCell();
		root.Layout.Add( WeaponAnimatorTheme.Button( "Close", "close", Close, root, true ) );
		Canvas = root;

		Button Toggle( string text, bool value, Action<bool> changed )
		{
			var button = new WeaponAnimatorButton( text, root )
			{
				IsToggle = true,
				IsChecked = value,
				Tint = WeaponAnimatorTheme.SurfaceRaised
			};
			button.Toggled = () => changed( button.IsChecked );
			return button;
		}

		Widget Number(
			string text,
			float value,
			float minimum,
			float maximum,
			Action<float> changed )
		{
			var container = new Widget( root );
			container.Layout = Layout.Column();
			container.Layout.Margin = 0;
			container.Layout.Spacing = 3;
			var row = RigAuditPanel.Row( container );
			row.Layout.Add( WeaponAnimatorTheme.Label( text, row, true ), 1 );
			var edit = new LineEdit( row )
			{
				Text = value.ToString( "0.##", CultureInfo.InvariantCulture ),
				FixedWidth = 82,
				FixedHeight = 27
			};
			edit.SetStyles( WeaponAnimatorTheme.InputStyle );
			var slider = new FloatSlider( container )
			{
				Minimum = minimum,
				Maximum = maximum,
				Value = value,
				FixedHeight = 18
			};

			void Apply( float candidate, bool updateEdit )
			{
				var clamped = Math.Clamp( candidate, minimum, maximum );
				if ( updateEdit )
					edit.Text = clamped.ToString( "0.##", CultureInfo.InvariantCulture );
				slider.Value = clamped;
				changed( clamped );
			}

			edit.TextEdited += textValue =>
			{
				if ( float.TryParse(
					textValue,
					NumberStyles.Float,
					CultureInfo.InvariantCulture,
					out var parsed )
					&& WeaponAnimationMath.IsFinite( parsed ) )
					Apply( parsed, false );
			};
			edit.EditingFinished += () => Apply( slider.Value, true );
			slider.OnValueEdited = () => Apply( slider.Value, true );
			row.Layout.Add( edit );
			container.Layout.Add( row );
			container.Layout.Add( slider );
			return container;
		}
	}
}
sonac.sbox-animator / Editor/Widgets/AnimationWorkspacePanels.cs
Editor library
#nullable enable annotations

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

namespace SboxWeaponAnimator.Editor;

public sealed class ClipRackPanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly ScrollArea _clipScroll;
	private readonly Widget _clipCanvas;
	private readonly ScrollArea _propertiesScroll;
	private readonly Widget _propertiesCanvas;
	private readonly Label _actionHint;
	private readonly Dictionary<Guid, WeaponAnimatorButton> _clipButtons = [];
	private readonly Dictionary<Guid, int> _propertyScrollByClip = [];
	private string _clipListSignature = "";
	private Guid _lastSelectedClipId;

	public event Action<string, ValidationSeverity>? StatusChanged;

	public ClipRackPanel(
		WeaponAnimatorController controller,
		Widget? parent = null,
		bool showClipHeader = true ) : base( parent )
	{
		_controller = controller;
		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 8 );
		Layout.Spacing = 6;

		if ( showClipHeader )
			Layout.Add( Header( "CLIP RACK", this ) );
		_clipScroll = new ScrollArea( this )
		{
			MinimumSize = new Vector2( 200, 70 )
		};
		_clipCanvas = new Widget( _clipScroll );
		_clipCanvas.Layout = Layout.Column();
		_clipCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
		_clipCanvas.Layout.Spacing = 2;
		_clipScroll.Canvas = _clipCanvas;
		Layout.Add( _clipScroll, 2 );

		var actions = RigAuditPanel.Row( this );
		actions.Layout.Add( WeaponAnimatorTheme.Button(
			"Start",
			"add_circle",
			StartSelectedFromDefault,
			actions,
			true ), 1 );
		actions.Layout.Add( WeaponAnimatorTheme.Button(
			"Duplicate",
			"content_copy",
			ShowDuplicateMenu,
			actions ), 1 );
		actions.Layout.Add( WeaponAnimatorTheme.Button(
			"Import",
			"input",
			ShowImportMenu,
			actions ), 1 );
		Layout.Add( actions );

		_actionHint = WeaponAnimatorTheme.Label( "", this, true );
		_actionHint.WordWrap = true;
		Layout.Add( _actionHint );

		_propertiesScroll = new ScrollArea( this ) { MinimumHeight = 80 };
		_propertiesCanvas = new Widget( _propertiesScroll );
		_propertiesCanvas.Layout = Layout.Column();
		_propertiesCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
		_propertiesCanvas.Layout.Spacing = 4;
		_propertiesScroll.Canvas = _propertiesCanvas;
		Layout.Add( _propertiesScroll, 1 );

		var addCustom = WeaponAnimatorTheme.Button(
			"Add custom clip",
			"playlist_add",
			AddCustomClip,
			this );
		Layout.Add( addCustom );

		_controller.DocumentChanged += Rebuild;
		Rebuild();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= Rebuild;
		_controller.SelectionChanged -= Rebuild;
		base.OnDestroyed();
	}

	private void Rebuild()
	{
		var clipScroll = _clipScroll.VerticalScrollbar.Value;
		var selectedClipId = _controller.Document.Workspace.SelectedClipId;
		var propertiesScroll = CapturePropertiesScroll( selectedClipId );
		var clipSignature = ClipListSignature();
		if ( _clipListSignature != clipSignature || _clipButtons.Count == 0 )
		{
			_clipCanvas.Layout.Clear( true );
			_clipButtons.Clear();

			AddClipGroup( "CORE", [
				WeaponClipRole.Idle, WeaponClipRole.Deploy, WeaponClipRole.Fire,
				WeaponClipRole.FireDry, WeaponClipRole.Reload, WeaponClipRole.ReloadEmpty,
				WeaponClipRole.Holster
			] );
			AddClipGroup( "PRESENTATION", [
				WeaponClipRole.Inspect, WeaponClipRole.Sprint, WeaponClipRole.Jump,
				WeaponClipRole.Lower, WeaponClipRole.Ironsights
			] );
			AddClipGroup( "INTERACTION", [
				WeaponClipRole.GrabStance, WeaponClipRole.GrabGestureOne,
				WeaponClipRole.GrabGestureTwo, WeaponClipRole.GrabGestureThree,
				WeaponClipRole.GrabGestureFour
			] );
			AddClipGroup( "INCREMENTAL", [
				WeaponClipRole.ReloadEnter, WeaponClipRole.FirstShell,
				WeaponClipRole.InsertShell, WeaponClipRole.ReloadExit
			] );

			var custom = _controller.Document.Clips
				.Where( x => x.Role == WeaponClipRole.Custom )
				.ToArray();
			if ( custom.Length > 0 )
			{
				_clipCanvas.Layout.Add( Header( "CUSTOM", _clipCanvas ) );
				foreach ( var clip in custom )
					AddClipButton( clip );
			}
			_clipCanvas.Layout.AddStretchCell();
			_clipListSignature = ClipListSignature();
			_clipCanvas.UpdateGeometry();
			_clipScroll.VerticalScrollbar.Value = clipScroll;
		}
		else
		{
			RefreshClipButtons();
		}

		_propertiesCanvas.Layout.Clear( true );

		var selected = _controller.Document.GetSelectedClip();
		_actionHint.Text = selected is null
			? "Select a clip."
			: selected.Readiness == ClipReadiness.NotStarted
				? "Not started · choose Start, Duplicate, or Import."
				: $"{selected.Readiness} · {selected.Duration:0.###} s at {selected.SampleRate:0.#} fps";
		BuildClipProperties( selected );
		_propertiesCanvas.UpdateGeometry();
		_propertiesScroll.VerticalScrollbar.Value = propertiesScroll;
		_lastSelectedClipId = selectedClipId;
	}

	private void AddClipGroup( string name, IEnumerable<WeaponClipRole> roles )
	{
		_clipCanvas.Layout.Add( Header( name, _clipCanvas ) );
		foreach ( var role in roles )
		{
			var clip = _controller.Document.EnsureClip( role );
			AddClipButton( clip );
		}
	}

	private void BuildClipProperties( WeaponAnimationClip? clip )
	{
		if ( _propertiesCanvas is null || clip is null )
			return;
		_propertiesCanvas.Layout.Add( Header( "CLIP PROPERTIES", _propertiesCanvas ) );
		if ( clip.Role == WeaponClipRole.Custom )
			AddCustomClipProperties( clip );
		var sequence = WeaponAnimatorTheme.Label(
			$"Sequence: {WeaponAnimationNames.SequenceName( clip )}",
			_propertiesCanvas,
			true );
		sequence.ToolTip = "Generated sequence name";
		_propertiesCanvas.Layout.Add( sequence );
		AddClipNumber(
			"Duration",
			clip.Duration,
			value => _controller.Mutate( "Clip duration", _ =>
			{
				clip.Duration = MathF.Max( value, 1.0f / clip.SampleRate );
				clip.KeysClampToDuration();
			} ) );
		AddClipNumber(
			"Sample rate",
			clip.SampleRate,
			value => _controller.Mutate(
				"Clip sample rate",
				_ => clip.SampleRate = Math.Clamp( value, 1, 240 ) ) );
		_propertiesCanvas.Layout.Add( ClipChoice(
			$"Readiness: {clip.Readiness}",
			Enum.GetNames<ClipReadiness>(),
			value => _controller.Mutate(
				"Clip readiness",
				_ => clip.Readiness = Enum.Parse<ClipReadiness>( value ) ) ) );
		_propertiesCanvas.Layout.Add( ClipChoice(
			$"Interpolation: {DominantInterpolation( clip )}",
			Enum.GetNames<TrackInterpolation>(),
			value => _controller.Mutate( "Track interpolation", _ =>
			{
				var interpolation = Enum.Parse<TrackInterpolation>( value );
				foreach ( var track in clip.Tracks )
					track.Interpolation = interpolation;
			} ) ) );
		_propertiesCanvas.Layout.Add( Header( "TAGS", _propertiesCanvas ) );
		var tagRow = RigAuditPanel.Row( _propertiesCanvas );
		var name = new LineEdit( tagRow )
		{
			PlaceholderText = "Tag name",
			FixedHeight = 27
		};
		name.SetStyles( WeaponAnimatorTheme.InputStyle );
		tagRow.Layout.Add( name, 1 );
		tagRow.Layout.Add( WeaponAnimatorTheme.Button(
			"Point",
			"add_location",
			() => AddClipTag( name.Text, AnimationTagKind.Point ),
			tagRow ) );
		tagRow.Layout.Add( WeaponAnimatorTheme.Button(
			"Range",
			"linear_scale",
			() => AddClipTag( name.Text, AnimationTagKind.Range ),
			tagRow ) );
		_propertiesCanvas.Layout.Add( tagRow );
		foreach ( var tag in clip.Tags )
		{
			_propertiesCanvas.Layout.Add( WeaponAnimatorTheme.Label(
				$"{tag.Name}  {tag.StartTime:0.###}–{tag.EndTime:0.###}",
				_propertiesCanvas,
				true ) );
		}
		_propertiesCanvas.Layout.AddStretchCell();
	}

	private void AddCustomClipProperties( WeaponAnimationClip clip )
	{
		if ( _propertiesCanvas is null )
			return;

		var nameRow = RigAuditPanel.Row( _propertiesCanvas );
		nameRow.Layout.Add( WeaponAnimatorTheme.Label( "Name", nameRow, true ) );
		var name = new LineEdit( nameRow )
		{
			Text = clip.Name,
			FixedHeight = 26
		};
		name.SetStyles( WeaponAnimatorTheme.InputStyle );
		name.EditingFinished += () =>
		{
			var renamed = name.Text.Trim();
			if ( string.IsNullOrWhiteSpace( renamed ) )
			{
				name.Text = clip.Name;
				StatusChanged?.Invoke(
					"A custom clip name cannot be empty.",
					ValidationSeverity.Warning );
				return;
			}
			_controller.RenameCustomClip( clip.Id, renamed );
		};
		nameRow.Layout.Add( name, 1 );
		_propertiesCanvas.Layout.Add( nameRow );

		var delete = (WeaponAnimatorButton)WeaponAnimatorTheme.Button(
			"Delete custom clip",
			"delete",
			() => RequestDeleteCustomClip( clip ),
			_propertiesCanvas );
		delete.Tint = WeaponAnimatorTheme.Coral * 0.38f;
		_propertiesCanvas.Layout.Add( delete );
	}

	private void RequestDeleteCustomClip( WeaponAnimationClip clip )
	{
		Dialog.AskConfirm(
			() => _controller.DeleteCustomClip( clip.Id ),
			$"Delete the custom clip '{clip.Name}' and all of its keys, curves, tags, and visibility tracks?",
			"Delete Custom Clip",
			"Delete",
			"Cancel" );
	}

	private void AddClipNumber( string label, float value, Action<float> changed )
	{
		if ( _propertiesCanvas is null )
			return;
		var row = RigAuditPanel.Row( _propertiesCanvas );
		row.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );
		var edit = new LineEdit( row )
		{
			Text = value.ToString( "0.###", CultureInfo.InvariantCulture ),
			FixedWidth = 84,
			FixedHeight = 26
		};
		edit.SetStyles( WeaponAnimatorTheme.InputStyle );
		edit.EditingFinished += () =>
		{
			if ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed )
				&& WeaponAnimationMath.IsFinite( parsed ) )
				changed( parsed );
		};
		row.Layout.Add( edit );
		_propertiesCanvas.Layout.Add( row );
	}

	private Button ClipChoice(
		string text,
		IEnumerable<string> values,
		Action<string> changed )
	{
		var button = new WeaponAnimatorButton( text, "expand_more", _propertiesCanvas )
		{
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Clicked = () =>
		{
			var menu = new Menu( button );
			foreach ( var value in values )
			{
				var captured = value;
				menu.AddOption( captured, null, () => changed( captured ) );
			}
			menu.OpenAt( button.ScreenRect.BottomLeft );
		};
		return button;
	}

	private void AddClipTag( string name, AnimationTagKind kind )
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null || string.IsNullOrWhiteSpace( name ) )
			return;
		_controller.Mutate( $"Add tag {name}", document =>
		{
			var start = document.Workspace.TimelineTime;
			clip.Tags.Add( new AnimationTag
			{
				Name = name.Trim(),
				Kind = kind,
				StartTime = start,
				EndTime = kind == AnimationTagKind.Range
					? MathF.Min( start + 0.1f, clip.Duration )
					: start
			} );
		} );
	}

	private static TrackInterpolation DominantInterpolation( WeaponAnimationClip clip ) =>
		clip.Tracks.GroupBy( x => x.Interpolation )
			.OrderByDescending( x => x.Count() )
			.Select( x => x.Key )
			.FirstOrDefault();

	private void AddClipButton( WeaponAnimationClip clip )
	{
		var button = new WeaponAnimatorButton( "", _clipCanvas )
		{
			Clicked = () => _controller.SelectClip( clip.Id )
		};
		ApplyClipButtonAppearance( button, clip );
		_clipCanvas.Layout.Add( button );
		_clipButtons[clip.Id] = button;
	}

	private void StartSelectedFromDefault()
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null )
			return;

		_controller.Mutate( $"Start {clip.Name}", document =>
		{
			document.Workspace.ClearWorkingPoses( clip.Id );
			document.Workspace.TimelineViews.RemoveAll( x => x.ClipId == clip.Id );
			document.Workspace.CurveViews.RemoveAll( x => x.ClipId == clip.Id );
			clip.VisibilityTracks.Clear();
			var skeleton = HostSkeletonBuilder.BuildCached( document );
			if ( clip.Role == WeaponClipRole.Idle )
			{
				IdleBindPoseService.SeedFromCurrentBind( document, skeleton );
				return;
			}

			clip.Tracks.Clear();
			clip.IsBindPoseSeed = false;
			foreach ( var bone in skeleton.Bones )
			{
				var track = clip.EnsureTrack( bone.Name );
				track.Kind = bone.IsWeaponBone ? RigControlKind.Weapon : RigControlKind.Arm;
				var gripTransform = document.Binding.GripPoses
					.FirstOrDefault( x => x.Id == document.Binding.DefaultGripPoseId )?
					.Bones.FirstOrDefault( x => x.BoneName.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) )?
					.LocalTransform;
				WeaponAnimationMath.UpsertKey(
					track,
					0,
					gripTransform ?? skeleton.GetBindLocal( bone ) );
			}
			clip.Readiness = clip.Role == WeaponClipRole.Idle
				? ClipReadiness.Ready
				: ClipReadiness.Draft;
		} );
	}

	private void ShowDuplicateMenu()
	{
		var selected = _controller.Document.GetSelectedClip();
		if ( selected is null )
			return;
		var menu = new Menu( this );
		foreach ( var source in _controller.Document.Clips.Where( x =>
			x.Id != selected.Id && x.Readiness != ClipReadiness.NotStarted ) )
		{
			var captured = source;
			menu.AddOption( captured.Name, null, () => Duplicate( captured, selected ) );
		}
		menu.OpenAtCursor();
	}

	private void Duplicate( WeaponAnimationClip source, WeaponAnimationClip destination )
	{
		_controller.Mutate( $"Duplicate {source.Name}", _ =>
		{
			_controller.Document.Workspace.ClearWorkingPoses( destination.Id );
			_controller.Document.Workspace.TimelineViews.RemoveAll( x =>
				x.ClipId == destination.Id );
			_controller.Document.Workspace.CurveViews.RemoveAll( x =>
				x.ClipId == destination.Id );
			var copy = Json.Deserialize<WeaponAnimationClip>( Json.Serialize( source ) )!;
			destination.Duration = copy.Duration;
			destination.SampleRate = copy.SampleRate;
			destination.AllowSubframeKeys = copy.AllowSubframeKeys;
			destination.IsBindPoseSeed = false;
			destination.Tracks = copy.Tracks;
			destination.VisibilityTracks = copy.VisibilityTracks;
			destination.Constraints = copy.Constraints;
			destination.Tags = copy.Tags;
			destination.Readiness = ClipReadiness.Draft;
		} );
	}

	private void ShowImportMenu()
	{
		var selected = _controller.Document.GetSelectedClip();
		if ( selected is null )
			return;
		var sequences = SequenceImportService.GetSequences( _controller.Document );
		if ( sequences.Count == 0 )
		{
			StatusChanged?.Invoke( "The source model exposes no importable sequences.", ValidationSeverity.Warning );
			return;
		}

		var menu = new Menu( this );
		foreach ( var sequence in sequences )
		{
			var captured = sequence;
			menu.AddOption( captured, null, () =>
			{
				SequenceImportResult? result = null;
				_controller.Mutate( $"Import {captured}", document =>
				{
					document.Workspace.ClearWorkingPoses( selected.Id );
					document.Workspace.TimelineViews.RemoveAll( x =>
						x.ClipId == selected.Id );
					document.Workspace.CurveViews.RemoveAll( x =>
						x.ClipId == selected.Id );
					selected.IsBindPoseSeed = false;
					result = SequenceImportService.Import( document, selected, captured );
				} );
				StatusChanged?.Invoke(
					result?.Message ?? "Sequence import failed.",
					result?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );
			} );
		}
		menu.OpenAtCursor();
	}

	private void AddCustomClip()
	{
		_controller.Mutate( "Add custom clip", document =>
		{
			var count = document.Clips.Count( x => x.Role == WeaponClipRole.Custom ) + 1;
			var clip = WeaponAnimationClip.Create( WeaponClipRole.Custom );
			clip.Name = $"Custom {count}";
			document.Clips.Add( clip );
			WeaponAnimationNames.RepairCustomSequenceNames( document );
			document.Workspace.SelectedClipId = clip.Id;
		} );
	}

	private static Label Header( string text, Widget parent )
	{
		var label = WeaponAnimatorTheme.SectionLabel( text, parent );
		label.FixedHeight = 22;
		label.SetStyles(
			"background-color: transparent; border: none; padding: 5px 0 0 0;" +
			$"font-size: 9px; font-weight: 600; letter-spacing: 0.65px; color: {WeaponAnimatorTheme.Muted.Hex};" );
		return label;
	}

	private int CapturePropertiesScroll( Guid selectedClipId )
	{
		if ( _propertiesScroll is null )
			return 0;

		if ( _lastSelectedClipId != Guid.Empty )
			_propertyScrollByClip[_lastSelectedClipId] =
				_propertiesScroll.VerticalScrollbar.Value;
		return _lastSelectedClipId == selectedClipId
			? _propertiesScroll.VerticalScrollbar.Value
			: _propertyScrollByClip.GetValueOrDefault( selectedClipId );
	}

	private string ClipListSignature() => string.Join(
		"|",
		_controller.Document.Clips.Select( x =>
			$"{x.Id}:{x.Role}:{x.Name}:{x.Readiness}" ) );

	private void RefreshClipButtons()
	{
		foreach ( var clip in _controller.Document.Clips )
		{
			if ( !_clipButtons.TryGetValue( clip.Id, out var button ) )
				continue;
			ApplyClipButtonAppearance( button, clip );
		}
	}

	private void ApplyClipButtonAppearance(
		WeaponAnimatorButton button,
		WeaponAnimationClip clip )
	{
		var marker = clip.Readiness switch
		{
			ClipReadiness.NotStarted => "○",
			ClipReadiness.Draft => "◐",
			ClipReadiness.Ready => "●",
			_ => "!"
		};
		button.Text = $"{marker}  {clip.Name}";
		button.Tint = clip.Id == _controller.Document.Workspace.SelectedClipId
			? WeaponAnimatorTheme.Cyan * 0.42f
			: clip.Readiness switch
			{
				ClipReadiness.Ready => WeaponAnimatorTheme.Green * 0.24f,
				ClipReadiness.Warning => WeaponAnimatorTheme.Coral * 0.28f,
				_ => WeaponAnimatorTheme.Surface
			};
		button.ToolTip = clip.Readiness.ToString();
	}

	internal ScrollArea ClipScroll => _clipScroll;
	internal ScrollArea? PropertiesScroll => _propertiesScroll;
	internal WeaponAnimatorButton? GetClipButton( Guid clipId ) =>
		_clipButtons.GetValueOrDefault( clipId );
}

public sealed class AnimationInspectorPanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly Widget _canvas;
	private readonly bool _controlToolsOnly;
	private readonly Dictionary<string, bool> _expandedSections = new( StringComparer.OrdinalIgnoreCase )
	{
		["binding"] = true,
		["constraints"] = true,
		["animgraph"] = false
	};
	public event Action<string, ValidationSeverity>? StatusChanged;

	public AnimationInspectorPanel(
		WeaponAnimatorController controller,
		Widget? parent = null,
		bool controlToolsOnly = false ) : base( parent )
	{
		_controller = controller;
		_controlToolsOnly = controlToolsOnly;
		Layout = Layout.Column();
		Layout.Margin = 0;
		var scroll = new ScrollArea( this );
		_canvas = new Widget( scroll );
		_canvas.Layout = Layout.Column();
		_canvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin( 10 );
		_canvas.Layout.Spacing = 7;
		scroll.Canvas = _canvas;
		Layout.Add( scroll, 1 );

		_controller.DocumentChanged += Rebuild;
		_controller.SelectionChanged += Rebuild;
		Rebuild();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= Rebuild;
		_controller.SelectionChanged -= Rebuild;
		base.OnDestroyed();
	}

	private void Rebuild()
	{
		_canvas?.Layout.Clear( true );
		if ( _canvas is null )
			return;

		if ( !_controlToolsOnly )
		{
			_canvas.Layout.Add( Header( "CONTROL INSPECTOR" ) );
			_canvas.Layout.Add( WeaponAnimatorTheme.Label( SelectionName(), _canvas ) );
		}

		var bindingCanvas = _controlToolsOnly
			? AddCollapsibleSection( "BINDING + GRIP POSES", "binding" )
			: _canvas;
		var selectedControl = _controller.Document.Workspace.SelectedControl;
		if ( !string.IsNullOrWhiteSpace( selectedControl ) )
		{
			var selectedTarget = ResolveControl( selectedControl );
			if ( selectedTarget is not null
				&& selectedControl is "@primary_hand" or "@support_hand" )
			{
				var instruction = WeaponAnimatorTheme.Label(
					"Keep this hand selected. Choose its attachment bone from the menu below; "
					+ "you do not need to select the weapon bone in the rig browser.",
					bindingCanvas,
					true );
				instruction.WordWrap = true;
				bindingCanvas.Layout.Add( instruction );

				var weaponBones = HostSkeletonBuilder.BuildCached( _controller.Document )
					.Bones
					.Where( x => x.IsWeaponBone )
					.Select( x => x.Name )
					.Distinct( StringComparer.OrdinalIgnoreCase )
					.ToList();
				bindingCanvas.Layout.Add( ChoiceButton(
					"Attachment bone",
					() => string.IsNullOrWhiteSpace( selectedTarget.AttachedBone )
						? "weapon_root (recommended on bind)"
						: selectedTarget.AttachedBone,
					weaponBones.Prepend( "(world)" ),
					value => _controller.Mutate( "Change hand attachment", document =>
					{
						HandAttachmentService.ChangeAttachment(
							document,
							selectedControl,
							value == "(world)" ? "" : value );
					} ),
					bindingCanvas ) );

				bindingCanvas.Layout.Add( WeaponAnimatorTheme.Button(
					selectedTarget.IsBound ? $"Unbind {selectedTarget.Name}" : $"Bind {selectedTarget.Name}",
					selectedTarget.IsBound ? "link_off" : "link",
					() => ToggleHandBinding( selectedControl ),
					bindingCanvas,
					!selectedTarget.IsBound ) );
			}
		}

		var bindingRow = RigAuditPanel.Row( bindingCanvas );
		bindingRow.Layout.Add( WeaponAnimatorTheme.Button(
			_controller.Document.Binding.Configuration == GripConfiguration.TwoHanded
				? "Two handed"
				: "One handed",
			"pan_tool",
			ToggleGripConfiguration,
			bindingRow ), 1 );
		bindingRow.Layout.Add( WeaponAnimatorTheme.Button(
			"Save grip pose",
			"save",
			SaveGripPose,
			bindingRow,
			true ), 1 );
		bindingCanvas.Layout.Add( bindingRow );
		bindingCanvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Apply saved grip pose",
			"front_hand",
			ShowGripPoseMenu,
			bindingCanvas ) );

		var clip = _controller.Document.GetSelectedClip();
		if ( clip is not null && !_controlToolsOnly )
		{
			_canvas.Layout.Add( Header( "CLIP PROPERTIES" ) );
			_canvas.Layout.Add( NumericField(
				"Duration (seconds)",
				clip.Duration,
				value => _controller.Mutate( "Clip duration", _ =>
				{
					clip.Duration = MathF.Max( value, 1.0f / clip.SampleRate );
					clip.KeysClampToDuration();
				} ) ) );
			_canvas.Layout.Add( NumericField(
				"Sample rate",
				clip.SampleRate,
				value => _controller.Mutate( "Clip sample rate", _ =>
					clip.SampleRate = Math.Clamp( value, 1, 240 ) ) ) );
			_canvas.Layout.Add( ChoiceButton(
				"Readiness",
				() => clip.Readiness.ToString(),
				Enum.GetNames<ClipReadiness>(),
				value => _controller.Mutate(
					"Clip readiness",
					_ => clip.Readiness = Enum.Parse<ClipReadiness>( value ) ) ) );
			_canvas.Layout.Add( ChoiceButton(
				"Interpolation",
				() => DominantInterpolation( clip ).ToString(),
				Enum.GetNames<TrackInterpolation>(),
				value => _controller.Mutate( "Track interpolation", _ =>
				{
					var interpolation = Enum.Parse<TrackInterpolation>( value );
					foreach ( var track in clip.Tracks )
						track.Interpolation = interpolation;
				} ) ) );
		}

		var constraintCanvas = _controlToolsOnly
			? AddCollapsibleSection( "CONSTRAINTS", "constraints" )
			: _canvas;
		if ( !_controlToolsOnly )
			constraintCanvas.Layout.Add( Header( "KEYING + CONSTRAINTS" ) );
		if ( !_controlToolsOnly )
		{
			var toggles = RigAuditPanel.Row( constraintCanvas );
			toggles.Layout.Add( ToggleButton(
				"Auto-key",
				_controller.Document.Workspace.AutoKey,
				value => _controller.Mutate( "Auto-key", d => d.Workspace.AutoKey = value ),
				toggles ), 1 );
			toggles.Layout.Add( ToggleButton(
				"Local gizmo",
				_controller.Document.Workspace.LocalGizmos,
				value => _controller.Mutate( "Gizmo space", d => d.Workspace.LocalGizmos = value ),
				toggles ), 1 );
			constraintCanvas.Layout.Add( toggles );
		}
		constraintCanvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Constraint target",
			"target",
			ShowConstraintTargetMenu,
			constraintCanvas ) );
		constraintCanvas.Layout.Add( WeaponAnimatorTheme.Label(
			string.IsNullOrWhiteSpace( _controller.Document.Workspace.ConstraintTargetBone )
				? "No constraint target selected"
				: _controller.Document.Workspace.ConstraintTargetBone,
			constraintCanvas,
			true ) );
		constraintCanvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Constrain selected control",
			"link",
			AddConstraint,
			constraintCanvas ) );

		if ( !_controlToolsOnly )
		{
			_canvas.Layout.Add( Header( "TAGS" ) );
			var tagRow = RigAuditPanel.Row( _canvas );
			var tagName = new LineEdit( tagRow )
			{
				PlaceholderText = "Tag name",
				FixedHeight = 28
			};
			tagName.SetStyles( WeaponAnimatorTheme.InputStyle );
			tagRow.Layout.Add( tagName, 1 );
			tagRow.Layout.Add( WeaponAnimatorTheme.Button(
				"Point",
				"add_location",
				() => AddTag( tagName.Text, AnimationTagKind.Point ),
				tagRow ) );
			tagRow.Layout.Add( WeaponAnimatorTheme.Button(
				"Range",
				"linear_scale",
				() => AddTag( tagName.Text, AnimationTagKind.Range ),
				tagRow ) );
			_canvas.Layout.Add( tagRow );

			if ( clip is not null )
			{
				foreach ( var tag in clip.Tags )
					_canvas.Layout.Add( WeaponAnimatorTheme.Label(
						$"{tag.Name}  {tag.StartTime:0.###}–{tag.EndTime:0.###}",
						_canvas,
						true ) );
			}
		}

		var graphCanvas = _controlToolsOnly
			? AddCollapsibleSection( "ANIMGRAPH PREVIEW", "animgraph" )
			: _canvas;
		if ( !_controlToolsOnly )
			graphCanvas.Layout.Add( Header( "ANIMGRAPH PREVIEW" ) );
		var graphActions = new[]
		{
			("Fire", "b_attack", WeaponClipRole.Fire),
			("Dry", "b_attack_dry", WeaponClipRole.FireDry),
			("Reload", "b_reload", WeaponClipRole.Reload),
			("Sprint", "b_sprint", WeaponClipRole.Sprint),
			("Inspect", "b_inspect", WeaponClipRole.Inspect)
		};
		var graphRows = new[]
		{
			RigAuditPanel.Row( graphCanvas ),
			RigAuditPanel.Row( graphCanvas )
		};
		for ( var index = 0; index < graphActions.Length; index++ )
		{
			var captured = graphActions[index];
			var row = graphRows[index < 3 ? 0 : 1];
			row.Layout.Add( WeaponAnimatorTheme.Button(
				captured.Item1,
				"play_arrow",
				() => SimulateParameter( captured.Item2, captured.Item3 ),
				row ), 1 );
		}
		graphCanvas.Layout.Add( graphRows[0] );
		graphCanvas.Layout.Add( graphRows[1] );
		graphCanvas.Layout.Add( NumericField(
			"move_bob",
			_controller.Document.Graph.PreviewFloats.GetValueOrDefault( "move_bob" ),
			value => _controller.Mutate( "Preview move_bob", d =>
				d.Graph.PreviewFloats["move_bob"] = Math.Clamp( value, 0, 1 ) ),
			graphCanvas ) );
		_canvas.Layout.AddStretchCell();
	}

	private Widget AddCollapsibleSection( string title, string id )
	{
		var expanded = _expandedSections.GetValueOrDefault( id );
		var header = new WeaponAnimatorButton(
			$"{(expanded ? "▾" : "▸")}  {title}",
			_canvas )
		{
			Clicked = () =>
			{
				_expandedSections[id] = !expanded;
				Rebuild();
			},
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		header.FixedHeight = 26;
		_canvas.Layout.Add( header );

		var body = new Widget( _canvas )
		{
			Visible = expanded,
			Layout = Layout.Column()
		};
		body.Layout.Margin = new Sandbox.UI.Margin( 2, 2, 2, 5 );
		body.Layout.Spacing = 6;
		_canvas.Layout.Add( body );
		return body;
	}

	private void ToggleHandBinding( string controlName )
	{
		var target = ResolveControl( controlName );
		if ( target is null )
			return;
		if ( !target.IsBound
			&& controlName == "@primary_hand"
			&& _controller.Document.Calibration.GetAnchor( AnchorKind.Grip ) is null )
		{
			StatusChanged?.Invoke(
				"Set the primary grip anchor in Calibrate before binding the primary hand.",
				ValidationSeverity.Warning );
			return;
		}

		_controller.Mutate(
			target.IsBound ? $"Unbind {target.Name}" : $"Bind {target.Name}",
			document =>
			{
				var bindingTarget = ResolveControl( controlName );
				if ( bindingTarget is null )
					return;

				if ( !bindingTarget.IsBound && controlName == "@primary_hand" )
					CalibrationBindingSeeder.SeedDefaultPrimaryHand( document );
				bindingTarget.IsBound = !bindingTarget.IsBound;
				bindingTarget.Reachable = true;

				var checklistId = controlName == "@primary_hand"
					? "primary_hand"
					: "support_hand";
				if ( bindingTarget.IsBound
					&& !document.Binding.CompletedChecklistItems.Contains( checklistId ) )
					document.Binding.CompletedChecklistItems.Add( checklistId );
			} );
	}

	private void SaveGripPose()
	{
		var document = _controller.Document;
		var skeleton = HostSkeletonBuilder.BuildCached( document );
		var pose = AnimationPoseEvaluator.Evaluate(
			document,
			skeleton,
			document.GetSelectedClip(),
			document.Workspace.TimelineTime,
			includeWorkingPose: true );
		_controller.Mutate( "Save default grip pose", d =>
		{
			var grip = new GripPose
			{
				Name = $"Grip {d.Binding.GripPoses.Count + 1}",
				Bones = skeleton.Bones
					.Where( x => !x.IsWeaponBone
						&& (x.Name.Contains( "finger_", StringComparison.OrdinalIgnoreCase )
							|| x.Name.Contains( "clavicle_", StringComparison.OrdinalIgnoreCase )
							|| x.Name.Contains( "hand_", StringComparison.OrdinalIgnoreCase )) )
					.Select( x => new BonePose
					{
						BoneName = x.Name,
						LocalTransform = pose.Local[x.Name]
					} )
					.ToList()
			};
			d.Binding.GripPoses.Add( grip );
			d.Binding.DefaultGripPoseId = grip.Id;
			d.Binding.CompletedChecklistItems.Add( "default_grip" );
		} );
	}

	private void ToggleGripConfiguration()
	{
		_controller.Mutate( "Grip configuration", d =>
			d.Binding.Configuration = d.Binding.Configuration == GripConfiguration.TwoHanded
				? GripConfiguration.OneHanded
				: GripConfiguration.TwoHanded );
	}

	private void ShowGripPoseMenu()
	{
		if ( _controller.Document.Binding.GripPoses.Count == 0 )
		{
			StatusChanged?.Invoke( "No reusable grip poses have been saved.", ValidationSeverity.Warning );
			return;
		}

		var menu = new Menu( this );
		foreach ( var grip in _controller.Document.Binding.GripPoses )
		{
			var captured = grip;
			menu.AddOption( captured.Name, null, () => ApplyGripPose( captured ) );
		}
		menu.OpenAtCursor();
	}

	private void ApplyGripPose( GripPose pose )
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null )
			return;

		_controller.Mutate( $"Apply {pose.Name}", document =>
		{
			var time = document.Workspace.TimelineTime;
			foreach ( var bone in pose.Bones )
			{
				var track = clip.EnsureTrack( bone.BoneName );
				track.Kind = RigControlKind.Arm;
				WeaponAnimationMath.UpsertKey( track, time, bone.LocalTransform );
			}
			clip.Readiness = clip.Role == WeaponClipRole.Idle
				? ClipReadiness.Ready
				: ClipReadiness.Draft;
			document.Binding.DefaultGripPoseId = pose.Id;
		} );
	}

	private void AddConstraint()
	{
		var clip = _controller.Document.GetSelectedClip();
		var source = _controller.Document.Workspace.SelectedControl;
		var target = _controller.Document.Workspace.ConstraintTargetBone;
		if ( clip is null || string.IsNullOrWhiteSpace( source ) || string.IsNullOrWhiteSpace( target ) )
		{
			StatusChanged?.Invoke(
				"Select an arm control and a weapon bone before adding a constraint.",
				ValidationSeverity.Warning );
			return;
		}

		_controller.Mutate( "Add timed constraint", _ => clip.Constraints.Add( new TimedConstraint
		{
			SourceControl = source,
			TargetBone = target,
			StartTime = _controller.Document.Workspace.TimelineTime,
			EndTime = clip.Duration
		} ) );
	}

	private void ShowConstraintTargetMenu()
	{
		var menu = new Menu( this );
		var weaponBones = _controller.Document.Rig.RetainedBones()
			.OrderBy( x => x.Name );
		foreach ( var bone in weaponBones )
		{
			var captured = bone.Name;
			menu.AddOption( captured, null, () => _controller.Mutate(
				"Constraint target",
				d => d.Workspace.ConstraintTargetBone = captured ) );
		}
		menu.OpenAtCursor();
	}

	private void AddTag( string name, AnimationTagKind kind )
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null || string.IsNullOrWhiteSpace( name ) )
			return;
		_controller.Mutate( $"Add tag {name}", document =>
		{
			var start = document.Workspace.TimelineTime;
			clip.Tags.Add( new AnimationTag
			{
				Name = name.Trim(),
				Kind = kind,
				StartTime = start,
				EndTime = kind == AnimationTagKind.Range
					? MathF.Min( start + 0.1f, clip.Duration )
					: start
			} );
		} );
	}

	private void SimulateParameter( string name, WeaponClipRole role )
	{
		var clip = _controller.Document.Clips.FirstOrDefault( x => x.Role == role );
		if ( clip is null )
			return;
		_controller.Document.Graph.PreviewBools[name] = true;
		_controller.SelectClip( clip.Id );
		StatusChanged?.Invoke(
			$"Simulating {name}=true with {(clip.Readiness == ClipReadiness.NotStarted ? "Idle fallback" : clip.Name)}.",
			clip.Readiness == ClipReadiness.NotStarted ? ValidationSeverity.Warning : ValidationSeverity.Info );
	}

	private string SelectionName()
	{
		var workspace = _controller.Document.Workspace;
		if ( !string.IsNullOrWhiteSpace( workspace.SelectedControl ) )
			return workspace.SelectedControl.TrimStart( '@' ).Replace( '_', ' ' );
		if ( !string.IsNullOrWhiteSpace( workspace.SelectedBone ) )
			return workspace.SelectedBone;
		return "No control selected";
	}

	private RigTarget? ResolveControl( string name ) => name switch
	{
		"@primary_hand" => _controller.Document.Binding.PrimaryHand,
		"@support_hand" => _controller.Document.Binding.SupportHand,
		"@primary_elbow" => _controller.Document.Binding.PrimaryElbowPole,
		"@support_elbow" => _controller.Document.Binding.SupportElbowPole,
		_ => null
	};

	private Widget NumericField(
		string name,
		float value,
		Action<float> changed,
		Widget? parent = null )
	{
		parent ??= _canvas;
		var row = RigAuditPanel.Row( parent );
		row.Layout.Add( WeaponAnimatorTheme.Label( name, row, true ), 1 );
		var edit = new LineEdit( row )
		{
			Text = value.ToString( "0.###", CultureInfo.InvariantCulture ),
			FixedHeight = 26,
			FixedWidth = 86
		};
		edit.SetStyles( WeaponAnimatorTheme.InputStyle );
		edit.EditingFinished += () =>
		{
			if ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )
				changed( parsed );
		};
		row.Layout.Add( edit );
		return row;
	}

	private Button ChoiceButton(
		string label,
		Func<string> current,
		IEnumerable<string> values,
		Action<string> changed,
		Widget? parent = null )
	{
		parent ??= _canvas;
		var button = new WeaponAnimatorButton( $"{label}: {current()}", "expand_more", parent )
		{
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Clicked = () =>
		{
			var menu = new Menu( button );
			foreach ( var value in values )
			{
				var captured = value;
				menu.AddOption( captured, null, () =>
				{
					changed( captured );
					button.Text = $"{label}: {current()}";
					button.FitToContent();
				} );
			}
			menu.OpenAt( button.ScreenRect.BottomLeft );
		};
		return button;
	}

	private static Button ToggleButton(
		string text,
		bool value,
		Action<bool> changed,
		Widget parent )
	{
		var button = new WeaponAnimatorButton( text, parent )
		{
			IsToggle = true,
			IsChecked = value,
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Toggled = () => changed( button.IsChecked );
		return button;
	}

	private Label Header( string text )
	{
		return WeaponAnimatorTheme.SectionLabel( text, _canvas, topMargin: true );
	}

	private static TrackInterpolation DominantInterpolation( WeaponAnimationClip clip ) =>
		clip.Tracks.GroupBy( x => x.Interpolation )
			.OrderByDescending( x => x.Count() )
			.Select( x => x.Key )
			.FirstOrDefault();
}

public sealed class AnimationTimelinePanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly TimelineEditorCanvas _timeline;
	private readonly TimelineControlToolbar _toolbar;
	private readonly Label _timeLabel;
	private readonly WeaponAnimatorButton _playButton;
	private readonly WeaponAnimatorButton _curvesButton;
	private readonly WeaponAnimatorButton _loopButton;

	public AnimationTimelinePanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		_toolbar = new TimelineControlToolbar( this );
		var left = _toolbar.LeftSection;
		left.Layout.Add( CompactAction( "Add key", "key", AddKey, left, true ) );
		left.Layout.Add( CompactAction( "Copy", "content_copy", _controller.CopySelectedKeys, left ) );
		left.Layout.Add( CompactAction( "Paste", "content_paste", _controller.PasteKeys, left ) );
		left.Layout.Add( CompactAction( "Delete", "delete", _controller.DeleteSelectedKeys, left ) );
		var reverse = CompactAction( "Reverse", "swap_horiz", _controller.ReverseKeys, left );
		reverse.ToolTip =
			"Reverse selected keys within their time range. With no selection, reverse the whole clip.";
		left.Layout.Add( reverse );
		_curvesButton = CompactAction(
			"Curves",
			"show_chart",
			() => _controller.SetCurveEditorVisible(
				!_controller.Document.Workspace.CurveEditorVisible ),
			left );
		_curvesButton.IsToggle = true;
		left.Layout.Add( _curvesButton );

		var player = _toolbar.CenterSection;
		player.Layout.Spacing = 3;
		player.Layout.Add( new Widget( player )
		{
			FixedWidth = 28,
			MinimumWidth = 28,
			FixedHeight = 26
		} );
		player.Layout.Add( PlayerButton( "first_page", "Jump to first frame", _controller.JumpToFirstFrame, player ) );
		player.Layout.Add( PlayerButton( "skip_previous", "Previous frame", () => _controller.StepTimelineFrame( -1 ), player ) );
		_playButton = PlayerButton( "play_arrow", "Play", _controller.TogglePlayback, player );
		player.Layout.Add( _playButton );
		player.Layout.Add( PlayerButton( "skip_next", "Next frame", () => _controller.StepTimelineFrame( 1 ), player ) );
		player.Layout.Add( PlayerButton( "last_page", "Jump to last frame", _controller.JumpToLastFrame, player ) );
		_loopButton = PlayerButton(
			"repeat",
			"Loop playback",
			_controller.ToggleSelectedClipLoop,
			player );
		_loopButton.IsToggle = true;
		_loopButton.Flat = true;
		player.Layout.Add( _loopButton );

		var right = _toolbar.RightSection;
		right.Layout.AddStretchCell();
		_timeLabel = WeaponAnimatorTheme.Label( "", right );
		right.Layout.Add( _timeLabel );
		_toolbar.FitSections();
		Layout.Add( _toolbar );

		_timeline = new TimelineEditorCanvas( controller, this );
		Layout.Add( _timeline, 1 );
		_controller.DocumentChanged += Refresh;
		_controller.TimelineChanged += Refresh;
		_controller.TimelineViewChanged += Refresh;
		_controller.PlaybackChanged += Refresh;
		_controller.ClipPlaybackSettingsChanged += Refresh;
		Refresh();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= Refresh;
		_controller.TimelineChanged -= Refresh;
		_controller.TimelineViewChanged -= Refresh;
		_controller.PlaybackChanged -= Refresh;
		_controller.ClipPlaybackSettingsChanged -= Refresh;
		base.OnDestroyed();
	}

	private void AddKey()
		=> _controller.KeySelectedTransform();

	private void Refresh()
	{
		var clip = _controller.Document.GetSelectedClip();
		if ( clip is null )
			_timeLabel.Text = "No clip";
		else
		{
			var frame = TimelineInteraction.TimeToFrame(
				_controller.Document.Workspace.TimelineTime,
				clip.SampleRate );
			var total = TimelineInteraction.LastFrame( clip );
			_timeLabel.Text =
				$"{_controller.Document.Workspace.TimelineTime:0.000}s · {frame:00} / {total:00}";
		}
		_playButton.Icon = _controller.IsPlaying ? "pause" : "play_arrow";
		_playButton.ToolTip = _controller.IsPlaying ? "Pause" : "Play";
		_loopButton.Enabled = clip is not null;
		_loopButton.IsChecked = clip?.Loop == true;
		_loopButton.Tint = clip?.Loop == true
			? WeaponAnimatorTheme.Cyan
			: WeaponAnimatorTheme.Muted;
		_loopButton.ToolTip = clip?.Loop == true
			? "Loop playback is enabled"
			: "Loop playback";
		var curves = _controller.Document.Workspace.CurveEditorVisible;
		_curvesButton.IsChecked = curves;
		_curvesButton.Text = curves ? "Keys" : "Curves";
		_curvesButton.Icon = curves ? "view_timeline" : "show_chart";
		_curvesButton.Tint = curves
			? WeaponAnimatorTheme.Cyan * 0.65f
			: WeaponAnimatorTheme.SurfaceRaised;
		_curvesButton.ToolTip = curves
			? "Return to the keyframe view"
			: "Open the curve editor";
		_curvesButton.FitToContent( true );
		_toolbar.FitSections();
		_timeline.Update();
	}

	private static WeaponAnimatorButton CompactAction(
		string text,
		string icon,
		Action clicked,
		Widget parent,
		bool primary = false )
	{
		var button = (WeaponAnimatorButton)WeaponAnimatorTheme.Button(
			text,
			icon,
			clicked,
			parent,
			primary );
		button.FixedHeight = 26;
		button.FitToContent( true );
		return button;
	}

	private static WeaponAnimatorButton PlayerButton(
		string icon,
		string tooltip,
		Action clicked,
		Widget parent )
	{
		var button = new WeaponAnimatorButton( "", icon, parent )
		{
			Clicked = clicked,
			FixedWidth = 28,
			FixedHeight = 26,
			Tint = WeaponAnimatorTheme.SurfaceRaised,
			ToolTip = tooltip
		};
		return button;
	}
}

internal sealed class TimelineControlToolbar : Widget
{
	public Widget LeftSection { get; }
	public Widget CenterSection { get; }
	public Widget RightSection { get; }

	public TimelineControlToolbar( Widget? parent = null ) : base( parent )
	{
		FixedHeight = 34;
		SetStyles( "background-color: rgb(24,27,30); border: none;" );
		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 7, 4, 7, 4 );
		Layout.Spacing = 0;

		LeftSection = Section( this );
		CenterSection = Section( this );
		RightSection = Section( this );
		Layout.Add( LeftSection );
		Layout.AddStretchCell();
		Layout.Add( RightSection );
		CenterSection.Raise();
	}

	public void FitSections()
	{
		LeftSection.FixedWidth = SectionWidth( LeftSection );
		CenterSection.FixedWidth = SectionWidth( CenterSection );
		PositionCenter();
	}

	protected override void OnResize()
	{
		base.OnResize();
		PositionCenter();
	}

	private void PositionCenter()
	{
		CenterSection.Position = new Vector2(
			CenteredLeft( Width, CenterSection.Width ),
			MathF.Round( (Height - CenterSection.Height) * 0.5f ) );
		CenterSection.Raise();
	}

	internal static float CenteredLeft( float toolbarWidth, float sectionWidth ) =>
		MathF.Round( (toolbarWidth - sectionWidth) * 0.5f );

	private static float SectionWidth( Widget section )
	{
		var children = section.Children.ToArray();
		if ( children.Length == 0 )
			return 0;
		return children.Sum( x => x is WeaponAnimatorButton button
			? string.IsNullOrWhiteSpace( button.Text )
				? 28
				: MathF.Ceiling( button.PreferredWidth )
			: MathF.Max( x.MinimumWidth, 0 ) )
			+ MathF.Max( children.Length - 1, 0 ) * section.Layout.Spacing;
	}

	private static Widget Section( Widget parent )
	{
		var section = new Widget( parent )
		{
			Layout = Layout.Row(),
			FixedHeight = 26
		};
		section.SetStyles( "background-color: transparent; border: none;" );
		section.Layout.Margin = 0;
		section.Layout.Spacing = 4;
		return section;
	}
}

internal static class ClipExtensions
{
	public static void KeysClampToDuration( this WeaponAnimationClip clip )
	{
		foreach ( var key in clip.Tracks.SelectMany( x => x.Keys ) )
			key.Time = Math.Clamp( key.Time, 0, clip.Duration );
		foreach ( var key in clip.VisibilityTracks.SelectMany( x => x.Keys ) )
			key.Time = Math.Clamp( key.Time, 0, clip.Duration );
		foreach ( var tag in clip.Tags )
		{
			tag.StartTime = Math.Clamp( tag.StartTime, 0, clip.Duration );
			tag.EndTime = Math.Clamp( tag.EndTime, tag.StartTime, clip.Duration );
		}
	}
}
sonac.sbox-animator / Editor/Widgets/WeaponAnimatorViewport.cs
Editor library
#nullable enable annotations

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

namespace SboxWeaponAnimator.Editor;

internal readonly record struct GridVisualStyle(
	float MinorOpacity,
	float MajorOpacity,
	float AxisOpacity,
	float MinorWidth,
	float MajorWidth,
	float AxisWidth )
{
	public static GridVisualStyle Resolve( float opacity, float lineWeight )
	{
		var alpha = Math.Clamp( opacity, 0, 0.5f );
		var weight = Math.Clamp( lineWeight, 0.1f, 2.0f );
		return new GridVisualStyle(
			alpha * 0.42f,
			alpha * 0.70f,
			alpha,
			weight * 0.38f,
			weight * 0.58f,
			weight * 0.78f );
	}
}

internal readonly record struct ViewportRimLightStyle(
	bool Enabled,
	float Intensity,
	Color Color )
{
	public static ViewportRimLightStyle Resolve(
		bool enabled,
		float intensity,
		bool fullBright )
	{
		var safeIntensity = WeaponAnimationMath.IsFinite( intensity )
			? Math.Clamp( intensity, 0, 12 )
			: 4.0f;
		return new ViewportRimLightStyle(
			enabled && !fullBright && safeIntensity > 0.001f,
			safeIntensity,
			WeaponAnimatorTheme.Cyan * safeIntensity );
	}
}

internal enum SkeletonBoneKind
{
	Weapon,
	Arm,
	Twist,
	Ik
}

/// <summary>
/// <paramref name="Hollow"/> draws the bone as a wireframe orb instead of a filled dot. Solid means
/// "you pose this directly"; hollow means the bone is derived - driven by a constraint or kept only
/// as an export helper. Shape reads at a glance where a size difference alone does not.
/// </summary>
internal readonly record struct SkeletonBoneStyle(
	bool Visible,
	Color Color,
	float AlphaScale,
	float RadiusScale,
	bool Hollow )
{
	/// <summary>
	/// The Facepunch arms ship four IK helper bones (`hand_*_to_*_ikrule`) kept through compilation
	/// by BoneMarkup even though they skin nothing, and the host builder adds `ik_hand_R`/`ik_hand_L`
	/// parented to weapon_root. Nothing reads any of them, and their default binding offset puts
	/// them well in front of the weapon, so they trail long lines across the viewport.
	/// </summary>
	public static SkeletonBoneKind Classify( HostBone bone )
	{
		// Checked ahead of the weapon test on purpose: weapon rigs commonly ship their own IK
		// targets (weapon_IK_hand_R), and those are helpers whichever rig they arrived from.
		// Hiding is display-only, so a false positive costs visibility, never generated output.
		if ( HasIkToken( bone.Name ) )
			return SkeletonBoneKind.Ik;
		if ( bone.IsWeaponBone )
			return SkeletonBoneKind.Weapon;

		// Twist bones deform the mesh, so they stay visible and clickable - just quieter.
		return bone.Name.Contains( "_twist", StringComparison.OrdinalIgnoreCase )
			? SkeletonBoneKind.Twist
			: SkeletonBoneKind.Arm;
	}

	/// <summary>
	/// Matches `ik` and `ikrule` as whole underscore-delimited tokens rather than as substrings, so
	/// `weapon_IK_hand_R` and `hand_R_to_weapon_ikrule` are caught while ordinary names that merely
	/// contain the letters - `spike`, `strike_plate` - are not.
	/// </summary>
	private static bool HasIkToken( string name )
	{
		foreach ( var token in name.Split( '_', StringSplitOptions.RemoveEmptyEntries ) )
		{
			if ( token.Equals( "ik", StringComparison.OrdinalIgnoreCase )
				|| token.Equals( "ikrule", StringComparison.OrdinalIgnoreCase ) )
				return true;
		}
		return false;
	}

	public static SkeletonBoneStyle Resolve(
		SkeletonBoneKind kind,
		int depth,
		int maxDepth,
		bool showIk )
	{
		if ( kind == SkeletonBoneKind.Weapon )
			return new SkeletonBoneStyle( true, WeaponAnimatorTheme.Amber, 1.0f, 1.0f, false );
		if ( kind == SkeletonBoneKind.Ik )
			return new SkeletonBoneStyle( showIk, WeaponAnimatorTheme.Coral, 1.0f, 1.0f, true );

		var fraction = maxDepth > 0
			? Math.Clamp( depth / (float)maxDepth, 0, 1 )
			: 0;
		var color = WeaponAnimatorTheme.BoneDepthColor( fraction );

		// Twist bones are driven by TiltTwist constraints, so they read as hollow. They keep close
		// to full size because a wireframe orb needs the room to be legible at all.
		return kind == SkeletonBoneKind.Twist
			? new SkeletonBoneStyle( true, color, 0.7f, 0.9f, true )
			: new SkeletonBoneStyle( true, color, 1.0f, 1.0f, false );
	}
}

internal readonly record struct SkeletonOverlayStyle(
	bool DrawThroughMeshes,
	float VisibleAlpha,
	float OccludedAlpha )
{
	/// <summary>
	/// Occluded bones use smaller marks so they stay readable without competing with visible bones.
	/// </summary>
	public const float OccludedDotScale = 0.55f;
	public const float OccludedLineThickness = 0.6f;
	public const int OcclusionGradientSegments = 10;

	/// <summary>
	/// Pushes an occluded bone most of the way to grey. Hue carries depth along the arm chain, so
	/// draining it is what makes "behind something" read as a different category rather than just a
	/// dimmer version of the same thing. A little colour is left so weapon and arm stay tellable.
	/// </summary>
	public static Color Occlude( Color color )
	{
		var luminance = (color.r * 0.299f) + (color.g * 0.587f) + (color.b * 0.114f);
		return Color.Lerp(
			color,
			new Color( luminance, luminance, luminance, color.a ),
			0.8f );
	}

	public static float OcclusionDepthClearance( float cameraDistance )
	{
		var safeDistance = WeaponAnimationMath.IsFinite( cameraDistance )
			? MathF.Max( cameraDistance, 0 )
			: 0;
		var markerRadius = Math.Clamp( safeDistance / 180.0f, 0.08f, 0.45f );
		return MathF.Max( markerRadius * 0.35f, 0.05f );
	}

	public static bool IsOccludingDepth(
		float targetDistance,
		float hitDistance )
	{
		return WeaponAnimationMath.IsFinite( targetDistance )
			&& WeaponAnimationMath.IsFinite( hitDistance )
			&& targetDistance - hitDistance > OcclusionDepthClearance( targetDistance );
	}

	public static SkeletonOverlayStyle Resolve( bool xray, float baseAlpha )
	{
		var safe = WeaponAnimationMath.IsFinite( baseAlpha )
			? Math.Clamp( baseAlpha, 0, 1 )
			: 1.0f;
		return new SkeletonOverlayStyle(
			xray && safe > 0.001f,
			safe,
			safe * 0.28f );
	}

	public SkeletonLineVisual ResolveLineVisual(
		SkeletonBoneStyle bone,
		bool occluded )
	{
		var color = occluded ? Occlude( bone.Color ) : bone.Color;
		var alpha = (occluded ? OccludedAlpha : VisibleAlpha)
			* bone.AlphaScale
			* 0.45f;
		return new SkeletonLineVisual(
			color.WithAlpha( alpha ),
			occluded ? OccludedLineThickness : 1.0f );
	}
}

internal readonly record struct SkeletonLineVisual(
	Color Color,
	float Thickness )
{
	public static SkeletonLineVisual Lerp(
		SkeletonLineVisual start,
		SkeletonLineVisual end,
		float fraction )
	{
		var t = WeaponAnimationMath.IsFinite( fraction )
			? Math.Clamp( fraction, 0, 1 )
			: 0;
		return new SkeletonLineVisual(
			Color.Lerp( start.Color, end.Color, t ),
			start.Thickness + ((end.Thickness - start.Thickness) * t) );
	}
}

internal static class SkeletonOcclusionPolicy
{
	public static bool IsOccludedByArm(
		bool targetIsWeaponBone,
		int targetArmSide,
		int hitArmSide )
	{
		return hitArmSide != 0
			&& (targetIsWeaponBone
				|| (targetArmSide != 0 && targetArmSide != hitArmSide));
	}
}

internal readonly record struct ArmPreviewVisualStyle(
	bool UseFlatMaterial,
	Color Tint )
{
	public static ArmPreviewVisualStyle Resolve(
		WeaponAnimatorStage stage,
		bool fullBright )
	{
		if ( fullBright )
		{
			return new ArmPreviewVisualStyle(
				true,
				new Color( 0.78f, 0.55f, 0.43f ) );
		}

		return stage == WeaponAnimatorStage.Animate
			? new ArmPreviewVisualStyle( false, Color.White )
			: new ArmPreviewVisualStyle(
				false,
				new Color( 0.42f, 0.84f, 0.92f, 0.42f ) );
	}
}

public enum WeaponAnimatorTransformMode
{
	Move,
	Rotate,
	Scale
}

internal sealed class RotationSnapStepWidget : Widget
{
	private readonly LineEdit _edit;
	private readonly Func<float> _getValue;
	private readonly Action<float> _setValue;

	public RotationSnapStepWidget(
		Func<float> getValue,
		Action<float> setValue,
		Widget parent ) : base( parent )
	{
		_getValue = getValue;
		_setValue = setValue;
		FixedWidth = 55;
		FixedHeight = 28;
		ToolTip = "Rotation snap angle";
		SetStyles(
			"background-color: rgb(20,23,26);" +
			"border: 1px solid rgba(255,255,255,0.09);" +
			"border-radius: 3px;" );
		Layout = Layout.Row();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		_edit = new LineEdit( this )
		{
			FixedHeight = 26,
			ToolTip = ToolTip
		};
		_edit.SetStyles(
			"background-color: transparent; border: none;" +
			"color: rgb(224,229,234); font-size: 11px;" +
			"text-align: right; padding: 0 1px 0 2px;" );
		_edit.TextEdited += ApplyText;
		_edit.EditingFinished += Refresh;
		Layout.Add( _edit, 1 );

		var suffix = WeaponAnimatorTheme.Label( "°", this );
		suffix.FixedWidth = 9;
		suffix.Alignment = TextFlag.Center;
		Layout.Add( suffix );

		var buttons = Layout.AddColumn();
		buttons.Add( new IconButton( "keyboard_arrow_up", () => Step( 1 ) )
		{
			Background = Color.Transparent,
			FixedWidth = 16,
			FixedHeight = 13,
			IconSize = 12,
			ToolTip = "Increase rotation snap angle"
		} );
		buttons.Add( new IconButton( "keyboard_arrow_down", () => Step( -1 ) )
		{
			Background = Color.Transparent,
			FixedWidth = 16,
			FixedHeight = 13,
			IconSize = 12,
			ToolTip = "Decrease rotation snap angle"
		} );
		Refresh();
	}

	public void Refresh()
	{
		if ( _edit.IsFocused )
			return;

		_edit.Text = _getValue().ToString( "0.##", CultureInfo.InvariantCulture );
		_edit.CursorPosition = 0;
		Update();
	}

	private void ApplyText( string text )
	{
		if ( float.TryParse(
			text,
			NumberStyles.Float,
			CultureInfo.InvariantCulture,
			out var value )
			&& WeaponAnimationMath.IsFinite( value ) )
			_setValue( value );
	}

	private void Step( int direction )
	{
		_edit.Blur();
		_setValue( WeaponAnimatorViewport.AdjustRotationSnapAngle(
			_getValue(),
			direction ) );
		Refresh();
	}
}

public sealed class WeaponAnimatorViewport : SceneRenderingWidget
{
	private const int LegacyIdleRepairVersion = 3;
	private const float ScaleGizmoSensitivity = 0.005f;
	private const string ArmsOccluderTag = "weaponanim_arms_occluder";
	private static readonly float[] RotationSnapSteps =
		[0.25f, 0.5f, 1, 5, 15, 30, 45, 90, 180];
	private Rect TransformReadoutRect =>
		new( 260, Width < 620 ? 46 : 10, 104, 28 );
	private readonly WeaponAnimatorController _controller;
	private readonly CameraComponent _camera;
	private readonly PointLight _rimLight;
	private readonly Material _flatArmsMaterial;
	private readonly WeaponAnimatorButton _moveModeButton;
	private readonly WeaponAnimatorButton _rotateModeButton;
	private readonly WeaponAnimatorButton _scaleModeButton;
	private readonly WeaponAnimatorButton _spaceButton;
	private readonly WeaponAnimatorButton _rotationSnapButton;
	private readonly RotationSnapStepWidget _rotationSnapStep;
	private readonly WeaponAnimatorButton _orbitCameraButton;
	private readonly WeaponAnimatorButton _freeLookCameraButton;
	private readonly WeaponAnimatorButton _lightingButton;
	private string _transformModeText = "";
	private SkinnedModelRenderer? _sourceRenderer;
	private SkinnedModelRenderer? _armsRenderer;
	private ModelHitboxes? _armsHitboxes;
	private SkinnedModelRenderer? _hostRenderer;
	private HostSkeleton? _hostSkeleton;
	private HostSkeleton? _boneDepthSource;
	private readonly Dictionary<string, int> _boneDepths =
		new( StringComparer.OrdinalIgnoreCase );
	private readonly Dictionary<string, Transform> _occlusionPose =
		new( StringComparer.OrdinalIgnoreCase );
	private readonly HashSet<string> _occludedBones =
		new( StringComparer.OrdinalIgnoreCase );
	private Transform _occlusionCameraTransform;
	private bool _occlusionCacheValid;
	private bool _occlusionFollowupPending;
	private string _lastOcclusionDiagnostic = "";
	private RealTimeSince _sinceOcclusionTrace = 99;
	private int _maxBoneDepth;
	private string _loadedSource = "";
	private string _loadedHost = "";
	private string _lastDiagnosticSelection = "";
	private int _legacyIdleRepairVersionChecked;
	private bool _sourcePoseDiagnosticsLogged;
	private int _sourcePoseDiagnosticFrames;
	private bool _armPoseDiagnosticsLogged;
	private int _armPoseDiagnosticFrames;
	private Vector2 _lastMouse;
	private string _calibrationGizmoTarget = "";
	private Transform _calibrationGizmoStartWorld;
	private Transform _calibrationGizmoStartLocal;
	private Vector3 _calibrationGizmoMoveDelta;
	private Vector3 _calibrationGizmoScaleDelta;
	private string _animationGizmoTarget = "";
	private RigControlKind _animationGizmoKind;
	private Transform _animationGizmoStartLocal;
	private Transform _animationGizmoStartWorld;
	private Transform? _animationGizmoStartParent;
	private Vector3 _animationGizmoMoveDelta;
	private Vector3 _animationGizmoScaleDelta;
	private RealTimeSince _sinceCameraSpeedChanged = 99;

	public ViewportPickMode PickMode { get; set; }

	/// <summary>
	/// Which custom anchor a <see cref="ViewportPickMode.CustomAnchor"/> pick will place.
	/// </summary>
	public Guid PickAnchorId { get; set; }
	public bool IsPlaying => _controller.IsPlaying;
	public WeaponAnimatorTransformMode TransformMode { get; private set; }
	public Vector3 ModelDimensions => _sourceRenderer?.Model?.Bounds.Size ?? Vector3.Zero;
	public bool ConsumesFreeLookMovementShortcut =>
		_controller.Document.Workspace.FreeLookCamera
		&& !_controller.Document.Workspace.FirstPersonPreview
		&& IsActiveWindow
		&& IsUnderMouse
		&& PickMode == ViewportPickMode.None;
	public event Action<string>? StatusChanged;
	public event Action<Vector3>? ModelDimensionsChanged;
	public event Action? LegacyIdleRepaired;

	public WeaponAnimatorViewport(
		WeaponAnimatorController controller,
		Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		MinimumSize = new Vector2( 420, 280 );
		FocusMode = FocusMode.Click;
		MouseTracking = true;
		Scene = Scene.CreateEditorScene();

		using ( Scene.Push() )
		{
			_camera = new GameObject( true, "weapon_animator_camera" )
				.GetOrAddComponent<CameraComponent>( false );
			_camera.BackgroundColor = WeaponAnimatorTheme.Background;
			_camera.ZNear = 0.5f;
			_camera.ZFar = 8192;
			_camera.Enabled = true;
			Camera = _camera;

			var ambient = new GameObject( true, "ambient" )
				.GetOrAddComponent<AmbientLight>( false );
			ambient.Color = new Color( 0.26f, 0.29f, 0.33f );
			ambient.Enabled = true;

			var key = new GameObject( true, "key_light" )
				.GetOrAddComponent<DirectionalLight>( false );
			key.WorldRotation = Rotation.From( 38, 135, 0 );
			key.LightColor = new Color( 1.0f, 0.92f, 0.82f ) * 1.3f;
			key.SkyColor = new Color( 0.18f, 0.22f, 0.27f );
			key.Enabled = true;

			_rimLight = new GameObject( true, "rim_light" )
				.GetOrAddComponent<PointLight>( false );
			_rimLight.WorldPosition = new Vector3( -32, 38, 28 );
			_rimLight.Radius = 160;
		}
		_flatArmsMaterial = Material.Load( "materials/dev/primary_white.vmat" );
		ApplyViewportRenderStyle();

		_moveModeButton = AddTransformModeButton(
			"open_with",
			"Move (W)",
			WeaponAnimatorTransformMode.Move,
			new Vector2( 10, 10 ) );
		_rotateModeButton = AddTransformModeButton(
			"360",
			"Rotate (E)",
			WeaponAnimatorTransformMode.Rotate,
			new Vector2( 41, 10 ) );
		_scaleModeButton = AddTransformModeButton(
			"zoom_out_map",
			"Scale (R)",
			WeaponAnimatorTransformMode.Scale,
			new Vector2( 72, 10 ) );
		_spaceButton = new WeaponAnimatorButton( "", "public", this )
		{
			IsToggle = true,
			Clicked = ToggleTransformSpace,
			Position = new Vector2( 119, 10 ),
			FixedWidth = 28,
			FixedHeight = 28
		};
		_spaceButton.Raise();
		_rotationSnapButton = new WeaponAnimatorButton(
			"",
			"rotate_90_degrees_cw",
			this )
		{
			IsToggle = true,
			Clicked = ToggleRotationSnap,
			Position = new Vector2( 166, 10 ),
			FixedWidth = 28,
			FixedHeight = 28,
			ToolTip = "Toggle rotation snapping"
		};
		_rotationSnapButton.Raise();
		_rotationSnapStep = new RotationSnapStepWidget(
			() => _controller.Document.Workspace.RotationSnapDegrees,
			SetRotationSnapDegrees,
			this )
		{
			Position = new Vector2( 197, 10 )
		};
		_rotationSnapStep.Raise();
		_orbitCameraButton = AddViewportActionButton(
			"360",
			"Orbit camera",
			() => SetCameraMode( false ) );
		_freeLookCameraButton = AddViewportActionButton(
			"videocam",
			"Free look camera — RMB look, WASD move, wheel changes speed, Shift moves faster",
			() => SetCameraMode( true ) );
		_lightingButton = AddViewportActionButton(
			"light_mode",
			"Toggle lit / full bright",
			ToggleViewportLighting );
		PositionViewportActions();

		_controller.DocumentChanged += OnDocumentChanged;
		_controller.PoseChanged += Update;
		_controller.SelectionChanged += OnSelectionChanged;
		_controller.TimelineChanged += Update;
		RefreshTransformOverlay();
		RefreshViewportCameraButtons();
		RebuildPreview();
	}

	protected override void OnResize()
	{
		base.OnResize();
		PositionViewportActions();
	}

	public override void OnDestroyed()
	{
		EndCalibrationGizmoDrag();
		EndAnimationGizmoDrag();
		_controller.DocumentChanged -= OnDocumentChanged;
		_controller.PoseChanged -= Update;
		_controller.SelectionChanged -= OnSelectionChanged;
		_controller.TimelineChanged -= Update;
		ReleasePreviewScene();
		base.OnDestroyed();
	}

	public void ReleasePreviewScene()
	{
		if ( Scene.IsValid() )
			Scene.Destroy();
		Scene = null;
		_sourceRenderer = null;
		_armsRenderer = null;
		_armsHitboxes = null;
		_hostRenderer = null;
		_hostSkeleton = null;
		_occlusionCacheValid = false;
	}

	public void TogglePlayback()
	{
		_controller.TogglePlayback();
	}

	public void StopPlayback()
	{
		_controller.PausePlayback();
	}

	public void SetTransformMode( WeaponAnimatorTransformMode mode )
	{
		if ( TransformMode == mode )
		{
			RefreshTransformOverlay();
			return;
		}

		EndCalibrationGizmoDrag();
		EndAnimationGizmoDrag();
		TransformMode = mode;
		RefreshTransformOverlay();
		StatusChanged?.Invoke( $"{TransformModeName( mode )} gizmo selected." );
		Update();
	}

	private WeaponAnimatorButton AddTransformModeButton(
		string icon,
		string tooltip,
		WeaponAnimatorTransformMode mode,
		Vector2 position )
	{
		var button = new WeaponAnimatorButton( "", icon, this )
		{
			IsToggle = true,
			Clicked = () => SetTransformMode( mode ),
			Position = position,
			FixedWidth = 28,
			FixedHeight = 28,
			ToolTip = tooltip
		};
		button.Raise();
		return button;
	}

	private WeaponAnimatorButton AddViewportActionButton(
		string icon,
		string tooltip,
		Action clicked )
	{
		var button = new WeaponAnimatorButton( "", icon, this )
		{
			IsToggle = true,
			Clicked = clicked,
			FixedWidth = 28,
			FixedHeight = 28,
			ToolTip = tooltip
		};
		button.Raise();
		return button;
	}

	private void PositionViewportActions()
	{
		if ( _lightingButton is null )
			return;

		var right = MathF.Max( Width - 10, 113 );
		_lightingButton.Position = new Vector2( right - 28, 10 );
		_freeLookCameraButton.Position = new Vector2( right - 72, 10 );
		_orbitCameraButton.Position = new Vector2( right - 103, 10 );
	}

	private void SetCameraMode( bool freeLook )
	{
		var workspace = _controller.Document.Workspace;
		if ( workspace.FreeLookCamera == freeLook )
		{
			RefreshViewportCameraButtons();
			return;
		}

		_controller.UpdateWorkspacePreference(
			freeLook ? "Free look camera" : "Orbit camera",
			state =>
			{
				state.FirstPersonPreview = false;
				if ( freeLook )
				{
					state.CameraPosition = _camera.WorldPosition;
				}
				else
				{
					var rotation = Rotation.From( state.CameraAngles );
					state.CameraFocus = state.CameraPosition
						+ rotation.Forward * state.CameraDistance;
				}
				state.FreeLookCamera = freeLook;
			} );
		RefreshViewportCameraButtons();
		UpdateCamera();
	}

	private void ToggleViewportLighting()
	{
		_controller.UpdateWorkspacePreference(
			"Viewport lighting",
			state => state.FullBrightViewport = !state.FullBrightViewport );
		RefreshViewportCameraButtons();
		UpdateCamera();
	}

	private void RefreshViewportCameraButtons()
	{
		var workspace = _controller.Document.Workspace;
		RefreshTransformModeButton(
			_orbitCameraButton,
			!workspace.FreeLookCamera );
		RefreshTransformModeButton(
			_freeLookCameraButton,
			workspace.FreeLookCamera );
		_lightingButton.IsChecked = workspace.FullBrightViewport;
		_lightingButton.Tint = workspace.FullBrightViewport
			? WeaponAnimatorTheme.Amber * 0.55f
			: WeaponAnimatorTheme.SurfaceRaised;
		_lightingButton.ToolTip = workspace.FullBrightViewport
			? "Full bright — click for Lit"
			: "Lit — click for Full bright";
	}

	private void ToggleTransformSpace()
	{
		_controller.Mutate(
			"Transform coordinate space",
			document => document.Workspace.LocalGizmos =
				!document.Workspace.LocalGizmos );
		RefreshTransformOverlay();
	}

	private void ToggleRotationSnap()
	{
		_controller.UpdateWorkspacePreference(
			"Rotation snapping",
			state => state.SnapRotation = !state.SnapRotation );
		RefreshTransformOverlay();
		Update();
	}

	private void SetRotationSnapDegrees( float value )
	{
		if ( !WeaponAnimationMath.IsFinite( value ) )
			return;

		_controller.UpdateWorkspacePreference(
			"Rotation snap angle",
			state => state.RotationSnapDegrees = Math.Clamp( value, 0.25f, 180.0f ) );
		RefreshTransformOverlay();
		Update();
	}

	private void RefreshTransformOverlay()
	{
		var workspace = _controller.Document.Workspace;
		var local = workspace.LocalGizmos;
		RefreshTransformModeButton(
			_moveModeButton,
			TransformMode == WeaponAnimatorTransformMode.Move );
		RefreshTransformModeButton(
			_rotateModeButton,
			TransformMode == WeaponAnimatorTransformMode.Rotate );
		RefreshTransformModeButton(
			_scaleModeButton,
			TransformMode == WeaponAnimatorTransformMode.Scale );
		_spaceButton.IsChecked = !local;
		_spaceButton.Tint = local
			? WeaponAnimatorTheme.SurfaceRaised
			: WeaponAnimatorTheme.Cyan * 0.55f;
		_spaceButton.ToolTip = local
			? "Local space — click for World"
			: "World space — click for Local";
		RefreshTransformModeButton(
			_rotationSnapButton,
			workspace.SnapRotation );
		_rotationSnapStep.Refresh();
		GizmoInstance.Settings.SnapToAngles = workspace.SnapRotation;
		GizmoInstance.Settings.AngleSpacing =
			WeaponAnimationMath.IsFinite( workspace.RotationSnapDegrees )
				? Math.Clamp( workspace.RotationSnapDegrees, 0.25f, 180.0f )
				: 15.0f;
		_transformModeText =
			$"{TransformModeName( TransformMode ).ToUpperInvariant()} · {(local ? "LOCAL" : "WORLD")}";
	}

	private static void RefreshTransformModeButton(
		WeaponAnimatorButton button,
		bool selected )
	{
		button.IsChecked = selected;
		button.Tint = selected
			? WeaponAnimatorTheme.Cyan * 0.55f
			: WeaponAnimatorTheme.SurfaceRaised;
	}

	private static string TransformModeName( WeaponAnimatorTransformMode mode ) =>
		mode switch
		{
			WeaponAnimatorTransformMode.Rotate => "Rotate",
			WeaponAnimatorTransformMode.Scale => "Scale",
			_ => "Move"
		};

	public void SetPickMode( ViewportPickMode mode, Guid anchorId = default )
	{
		PickMode = mode;
		PickAnchorId = anchorId;
		StatusChanged?.Invoke( mode == ViewportPickMode.None
			? "Pick mode cleared."
			: $"Click the weapon surface or a bone to set {PickLabel( mode )}." );
	}

	public void FitCamera()
	{
		var bounds = _sourceRenderer?.Bounds ?? _hostRenderer?.Bounds;
		if ( bounds is null )
			return;

		var workspace = _controller.Document.Workspace;
		workspace.CameraFocus = bounds.Value.Center;
		workspace.CameraDistance =
			MathF.Max( bounds.Value.Size.Length * 1.25f, 12 );
		if ( workspace.FreeLookCamera )
		{
			var rotation = Rotation.From( workspace.CameraAngles );
			workspace.CameraPosition = workspace.CameraFocus
				- rotation.Forward * workspace.CameraDistance;
		}
		UpdateCamera();
	}

	public void RebuildPreview()
	{
		if ( !Scene.IsValid() )
			return;

		_occlusionCacheValid = false;
		using ( Scene.Push() )
		{
			_sourceRenderer?.GameObject.Destroy();
			_armsRenderer?.GameObject.Destroy();
			_hostRenderer?.GameObject.Destroy();
			_sourceRenderer = null;
			_armsRenderer = null;
			_armsHitboxes = null;
			_hostRenderer = null;
			_hostSkeleton = null;
			_loadedSource = "";
			_loadedHost = "";
			_sourcePoseDiagnosticsLogged = false;
			_sourcePoseDiagnosticFrames = 0;
			_armPoseDiagnosticsLogged = false;
			_armPoseDiagnosticFrames = 0;

			var document = _controller.Document;
			if ( !string.IsNullOrWhiteSpace( document.Source.CompiledModelPath ) )
			{
				// Remember failed loads too. Retrying a full scene rebuild every frame creates
				// overlapping renderers while the scene processes deferred destruction.
				_loadedSource = document.Source.CompiledModelPath;
				var sourceModel = Model.Load( document.Source.CompiledModelPath );
				if ( sourceModel is not null && !sourceModel.IsError )
				{
					var sourceObject = new GameObject( true, "source_weapon_preview" );
					_sourceRenderer = sourceObject.GetOrAddComponent<SkinnedModelRenderer>( false );
					_sourceRenderer.Model = sourceModel;
					_sourceRenderer.Enabled = true;
					ModelDimensionsChanged?.Invoke( sourceModel.Bounds.Size );
				}
				else
				{
					Log.Warning(
						$"[Weapon Animator] source preview model is unavailable: "
						+ $"'{document.Source.CompiledModelPath}'. "
						+ "The viewport will wait for a path change or a manual rebuild." );
				}
			}

			var armsModel = Model.Load( HostSkeletonBuilder.ProductionArmsModel );
			if ( armsModel is null || armsModel.IsError )
				armsModel = HostSkeletonBuilder.LoadArmProfile();
			if ( armsModel is not null && !armsModel.IsError )
			{
				var armsObject = new GameObject( true, "facepunch_arms_preview" );
				armsObject.Tags.Add( ArmsOccluderTag );
				_armsRenderer = armsObject.GetOrAddComponent<SkinnedModelRenderer>( false );
				_armsRenderer.Model = armsModel;
				_armsRenderer.Enabled = true;
				_armsRenderer.Tint = new Color( 0.42f, 0.84f, 0.92f, 0.42f );
				_armsHitboxes = armsObject.GetOrAddComponent<ModelHitboxes>( false );
				_armsHitboxes.Renderer = _armsRenderer;
				_armsHitboxes.Target = armsObject;
				_armsHitboxes.Enabled = true;
			}

			if ( document.ActiveStage == WeaponAnimatorStage.Animate
				&& !string.IsNullOrWhiteSpace( document.Source.PreviewHostPath ) )
			{
				// Failed host loads wait for a path change or an explicit rebuild.
				_loadedHost = document.Source.PreviewHostPath;
				var hostModel = Model.Load( document.Source.PreviewHostPath );
				if ( hostModel is not null && !hostModel.IsError )
				{
					_hostRenderer = new GameObject( true, "animation_host_preview" )
						.GetOrAddComponent<SkinnedModelRenderer>( false );
					_hostRenderer.Model = hostModel;
					_hostRenderer.Enabled = true;
					_hostRenderer.UseAnimGraph = false;
					SuppressHostRendering();
					_hostSkeleton = HostSkeletonBuilder.BuildCached( document );

					if ( _sourceRenderer.IsValid() )
					{
						_sourceRenderer!.WorldTransform = Transform.Zero;
						_sourceRenderer.BoneMergeTarget = null;
					}
					if ( _armsRenderer.IsValid() )
					{
						_armsRenderer!.WorldTransform = Transform.Zero;
						_armsRenderer.BoneMergeTarget = null;
						_armsRenderer.Tint = Color.White;
					}
				}
				else
				{
					Log.Warning(
						$"[Weapon Animator] animation host preview is unavailable: "
						+ $"'{document.Source.PreviewHostPath}'. "
						+ "The viewport will wait for a path change or a manual rebuild." );
				}
			}
		}

		if ( _controller.Document.Workspace.CameraDistance <= 0 )
			FitCamera();
		Update();
	}

	protected override void PreFrame()
	{
		Scene.EditorTick( RealTime.Now, RealTime.Delta );
		GizmoInstance.Input.IsHovered = IsActiveWindow && IsUnderMouse;
		UpdateGizmoInputs( GizmoInstance.Input.IsHovered );
		FinishCalibrationGizmoDragIfReleased();
		FinishAnimationGizmoDragIfReleased();

		if ( RepairLegacyIdleIfNeeded() )
			return;
		EnsurePreviewCurrent();
		AdvancePlayback();
		UpdateFreeLookMovement();
		UpdateCamera();
		ApplyViewportRenderStyle();

		DrawWorkspaceGrid();
		if ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )
			DrawCalibration();
		else
			DrawAnimation();

		DrawScreenGuides();
		DrawViewportToolReadout();
		DrawCameraSpeedOverlay();
		Cursor = Gizmo.HasHovered || PickMode != ViewportPickMode.None
			? CursorShape.Finger
			: _controller.Document.Workspace.FreeLookCamera
				&& global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Right )
				&& IsUnderMouse
					? CursorShape.Blank
					: CursorShape.Arrow;
	}

	private void DrawWorkspaceGrid()
	{
		var style = GridVisualStyle.Resolve(
			_controller.Document.Workspace.GridOpacity,
			_controller.Document.Workspace.GridLineThickness );
		if ( style.AxisOpacity <= 0 )
			return;

		var spacing = MathF.Max( Gizmo.Settings.GridSpacing, 1 );
		var desiredExtent = MathF.Max(
			128,
			_controller.Document.Workspace.CameraDistance * 6 );
		var halfLines = Math.Clamp(
			(int)MathF.Ceiling( desiredExtent / spacing ),
			8,
			64 );
		var extent = halfLines * spacing;

		using var scope = Gizmo.Scope( "weapon_animator_grid" );
		for ( var index = -halfLines; index <= halfLines; index++ )
		{
			if ( index == 0 )
				continue;

			var coordinate = index * spacing;
			var major = index % 4 == 0;
			Gizmo.Draw.Color = Color.White.WithAlpha(
				major ? style.MajorOpacity : style.MinorOpacity );
			Gizmo.Draw.LineThickness = major ? style.MajorWidth : style.MinorWidth;
			Gizmo.Draw.Line(
				new Vector3( coordinate, -extent, 0 ),
				new Vector3( coordinate, extent, 0 ) );
			Gizmo.Draw.Line(
				new Vector3( -extent, coordinate, 0 ),
				new Vector3( extent, coordinate, 0 ) );
		}

		Gizmo.Draw.LineThickness = style.AxisWidth;
		Gizmo.Draw.Color = new Color( 0.90f, 0.28f, 0.38f ).WithAlpha( style.AxisOpacity );
		Gizmo.Draw.Line( new Vector3( -extent, 0, 0 ), new Vector3( extent, 0, 0 ) );
		Gizmo.Draw.Color = new Color( 0.58f, 0.78f, 0.20f ).WithAlpha( style.AxisOpacity );
		Gizmo.Draw.Line( new Vector3( 0, -extent, 0 ), new Vector3( 0, extent, 0 ) );
		Gizmo.Draw.LineThickness = 1;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );
		var delta = e.LocalPosition - _lastMouse;
		_lastMouse = e.LocalPosition;
		if ( (e.ButtonState & MouseButtons.Right) == 0
			|| _controller.Document.Workspace.FirstPersonPreview )
			return;

		var workspace = _controller.Document.Workspace;
		workspace.CameraAngles = new Angles(
			Math.Clamp( workspace.CameraAngles.pitch + delta.y * 0.22f, -88, 88 ),
			workspace.CameraAngles.yaw - delta.x * 0.22f,
			0 );
		_controller.MarkWorkspacePreferenceChanged( "Viewport camera rotation" );
		UpdateCamera();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );
		_lastMouse = e.LocalPosition;
		if ( !e.LeftMouseButton || PickMode == ViewportPickMode.None )
			return;

		if ( TryPickSourceSurface( e.LocalPosition, out var localPosition ) )
		{
			ApplyPickedPoint( localPosition );
			e.Accepted = true;
		}
	}

	protected override void OnMouseWheel( WheelEvent e )
	{
		if ( _controller.Document.Workspace.FirstPersonPreview )
			return;

		var workspace = _controller.Document.Workspace;
		if ( workspace.FreeLookCamera )
		{
			var direction = Math.Sign( e.Delta );
			_controller.UpdateWorkspacePreference(
				"Free look camera speed",
				state => state.CameraMoveSpeed = AdjustCameraSpeed(
					state.CameraMoveSpeed,
					direction ) );
			_sinceCameraSpeedChanged = 0;
			e.Accept();
			Update();
			return;
		}

		workspace.CameraDistance = Math.Clamp(
			workspace.CameraDistance * (e.Delta > 0 ? 0.9f : 1.1f),
			2,
			4096 );
		_controller.MarkWorkspacePreferenceChanged( "Orbit camera distance" );
		e.Accept();
	}

	private void OnDocumentChanged()
	{
		_occlusionCacheValid = false;
		RefreshTransformOverlay();
		RefreshViewportCameraButtons();
		var document = _controller.Document;
		if ( document.Source.CompiledModelPath != _loadedSource
			|| (document.ActiveStage == WeaponAnimatorStage.Animate
				&& document.Source.PreviewHostPath != _loadedHost)
			|| (document.ActiveStage == WeaponAnimatorStage.Calibrate && _hostRenderer.IsValid()) )
		{
			RebuildPreview();
			return;
		}

		Update();
	}

	private void EnsurePreviewCurrent()
	{
		if ( !Scene.IsValid() )
			return;
		var requestedSource = _controller.Document.Source.CompiledModelPath;
		if ( _sourceRenderer is null
			&& ShouldRetryMissingSourcePreview( requestedSource, _loadedSource ) )
			RebuildPreview();
	}

	internal static bool ShouldRetryMissingSourcePreview(
		string requestedSource,
		string attemptedSource ) =>
		!string.IsNullOrWhiteSpace( requestedSource )
		&& !requestedSource.Equals( attemptedSource, StringComparison.OrdinalIgnoreCase );

	private void AdvancePlayback()
	{
		_controller.AdvancePlayback( RealTime.Delta );
	}

	private void UpdateCamera()
	{
		if ( !_camera.IsValid() )
			return;

		var document = _controller.Document;
		_camera.DebugMode = document.Workspace.FullBrightViewport
			? SceneCameraDebugMode.FullBright
			: SceneCameraDebugMode.Normal;
		if ( document.Workspace.FirstPersonPreview )
		{
			_camera.WorldPosition = Vector3.Zero;
			_camera.WorldRotation = Rotation.Identity;
			var aspect = GuideAspect( document.Calibration.AspectGuide );
			var horizontalRadians = document.Calibration.HorizontalFov.DegreeToRadian();
			_camera.FieldOfView = (2.0f * MathF.Atan(
				MathF.Tan( horizontalRadians * 0.5f ) / aspect )).RadianToDegree();
			return;
		}

		var rotation = Rotation.From( document.Workspace.CameraAngles );
		if ( document.Workspace.FreeLookCamera )
		{
			_camera.WorldPosition = document.Workspace.CameraPosition;
			_camera.WorldRotation = rotation;
			_camera.FieldOfView = 48;
			return;
		}

		var focus = document.Workspace.CameraFocus;
		_camera.WorldPosition = focus - rotation.Forward * document.Workspace.CameraDistance;
		_camera.WorldRotation = Rotation.LookAt( focus - _camera.WorldPosition, Vector3.Up );
		_camera.FieldOfView = 48;
	}

	private void ApplyViewportRenderStyle()
	{
		var document = _controller.Document;
		var rim = ViewportRimLightStyle.Resolve(
			document.Workspace.RimLightEnabled,
			document.Workspace.RimLightIntensity,
			document.Workspace.FullBrightViewport );
		_rimLight.Enabled = rim.Enabled;
		_rimLight.LightColor = rim.Color;

		if ( !_armsRenderer.IsValid() )
			return;
		var arms = ArmPreviewVisualStyle.Resolve(
			document.ActiveStage,
			document.Workspace.FullBrightViewport );
		_armsRenderer!.MaterialOverride = arms.UseFlatMaterial
			? _flatArmsMaterial
			: null;
		_armsRenderer.Tint = arms.Tint;
	}

	private void UpdateFreeLookMovement()
	{
		var workspace = _controller.Document.Workspace;
		if ( !workspace.FreeLookCamera
			|| workspace.FirstPersonPreview
			|| !IsActiveWindow
			|| !IsUnderMouse
			|| PickMode != ViewportPickMode.None
			|| Gizmo.Pressed.Any )
			return;

		var rotation = Rotation.From( workspace.CameraAngles );
		var movement = Vector3.Zero;
		if ( global::Editor.Application.IsKeyDown( KeyCode.W ) )
			movement += rotation.Forward;
		if ( global::Editor.Application.IsKeyDown( KeyCode.S ) )
			movement += rotation.Backward;
		if ( global::Editor.Application.IsKeyDown( KeyCode.A ) )
			movement += rotation.Left;
		if ( global::Editor.Application.IsKeyDown( KeyCode.D ) )
			movement += rotation.Right;
		if ( movement.IsNearZeroLength )
			return;

		var fast = global::Editor.Application.KeyboardModifiers
			.HasFlag( KeyboardModifiers.Shift );
		var speed = workspace.CameraMoveSpeed * 100.0f * (fast ? 8.0f : 1.0f);
		workspace.CameraPosition += movement.Normal * speed * RealTime.Delta;
		_controller.MarkWorkspacePreferenceChanged( "Free look camera position" );
	}

	internal static float AdjustCameraSpeed( float currentSpeed, int direction )
	{
		currentSpeed = Math.Clamp( currentSpeed, 0.25f, 100.0f );
		var adjustment = currentSpeed < 5.0f
			? 0.25f
			: currentSpeed < 20.0f
				? 1.0f
				: MathF.Round( currentSpeed * 0.1f / 2.5f ) * 2.5f;
		return Math.Clamp(
			currentSpeed + adjustment * Math.Sign( direction ),
			0.25f,
			100.0f );
	}

	internal static float AdjustRotationSnapAngle( float currentAngle, int direction )
	{
		if ( !WeaponAnimationMath.IsFinite( currentAngle ) )
			currentAngle = 15;

		var nearest = 0;
		var nearestDistance = float.MaxValue;
		for ( var index = 0; index < RotationSnapSteps.Length; index++ )
		{
			var distance = MathF.Abs( currentAngle - RotationSnapSteps[index] );
			if ( distance >= nearestDistance )
				continue;
			nearest = index;
			nearestDistance = distance;
		}

		var target = Math.Clamp(
			nearest + Math.Sign( direction ),
			0,
			RotationSnapSteps.Length - 1 );
		return RotationSnapSteps[target];
	}

	private void DrawCalibration()
	{
		var document = _controller.Document;
		if ( _sourceRenderer.IsValid() )
		{
			_sourceRenderer!.BoneMergeTarget = null;
			_sourceRenderer.WorldTransform = WeaponAnimationMath.Compose(
				document.Calibration.PhysicalTransform,
				document.Calibration.FramingTransform );
			_sourceRenderer.ClearPhysicsBones();
		}

		if ( _armsRenderer.IsValid() )
		{
			_armsRenderer!.BoneMergeTarget = null;
			_armsRenderer.WorldTransform = Transform.Zero;
		}

		DrawMeasurement();
		DrawAnchors();
		if ( document.Workspace.ShowSkeleton )
			DrawRendererSkeleton( _sourceRenderer, WeaponAnimatorTheme.Amber, allowXray: true );

		// Calibration only ever poses the weapon as a whole, plus its anchors. Selecting a bone
		// no longer suppresses the rig gizmo, which previously left the page with no gizmo at all.
		if ( CalibrationSelection.Resolve( document, document.Workspace.SelectedControl ) is { } anchor )
			DrawSelectedAnchorControl( anchor );
		else
			DrawWholeRigControl();
	}

	private void DrawAnimation()
	{
		if ( !_hostRenderer.IsValid() || _hostSkeleton is null )
			return;

		var document = _controller.Document;
		var clip = document.GetSelectedClip();
		var pose = AnimationPoseEvaluator.Evaluate(
			document,
			_hostSkeleton,
			clip,
			document.Workspace.TimelineTime,
			includeWorkingPose: true );

		_hostRenderer!.ClearPhysicsBones();
		foreach ( var bone in _hostRenderer.Model.Bones.AllBones )
		{
			if ( pose.Model.TryGetValue( bone.Name, out var modelTransform ) )
				_hostRenderer.SetBoneTransform( bone, modelTransform );
		}
		SuppressHostRendering();
		ApplyWeaponPoseToSourceRenderer( pose );
		ApplyArmPoseToRenderer( pose );

		document.Binding.PrimaryHand.Reachable = pose.PrimaryReachable;
		document.Binding.SupportHand.Reachable = pose.SupportReachable;
		DrawGripTethers( pose );
		if ( document.Workspace.ShowSkeleton )
			DrawHostSkeleton( pose, 1.0f, useRenderedArms: true, allowXray: true );
		if ( document.Workspace.ShowOnionSkins && clip is not null )
			DrawOnionSkins( clip );
		DrawAnimationControl();
	}

	private void ApplyWeaponPoseToSourceRenderer( EvaluatedPose pose )
	{
		if ( !_sourceRenderer.IsValid() || _hostSkeleton is null )
			return;

		var document = _controller.Document;
		var sourceRoot = document.Rig.FindBone( document.Rig.SourceSkeletonRootId );
		var rootTransform = WeaponAnimationMath.Compose(
			document.Calibration.PhysicalTransform,
			document.Calibration.FramingTransform );
		if ( sourceRoot is not null
			&& pose.Model.TryGetValue( "weapon_root", out var desiredRootWorld ) )
		{
			rootTransform = WeaponPoseProjection.SolveRendererTransform(
				sourceRoot.BindModelTransform,
				desiredRootWorld );
		}

		_sourceRenderer!.BoneMergeTarget = null;
		_sourceRenderer.WorldTransform = rootTransform;
		_sourceRenderer.ClearPhysicsBones();
		foreach ( var definition in document.Rig.RetainedBones() )
		{
			if ( definition.Id.Equals(
				document.Rig.SourceSkeletonRootId,
				StringComparison.OrdinalIgnoreCase ) )
				continue;

			var sourceBone = _sourceRenderer.Model.Bones.GetBone( definition.Name );
			if ( sourceBone is not null
				&& WeaponPoseProjection.TryGetSourceWorldOverride(
					document,
					pose,
					definition,
					out var transform )
				&& _hostSkeleton.ByName.TryGetValue( definition.Name, out var hostBone )
				&& pose.Local.TryGetValue( definition.Name, out var currentLocal )
				&& !WeaponPoseProjection.TransformNear(
					currentLocal,
					_hostSkeleton.GetBindLocal( hostBone ) ) )
			{
				// Native bind transforms remain untouched; only authored deltas use overrides.
				_sourceRenderer.SetBoneTransform(
					sourceBone,
					_sourceRenderer.WorldTransform.ToLocal( transform ) );
			}
		}
		ApplyPreviewVisibility();
		LogSourcePoseDiagnostics( pose );
	}

	private void ApplyPreviewVisibility()
	{
		if ( !_sourceRenderer.IsValid() )
			return;

		var document = _controller.Document;
		var clip = document.GetSelectedClip();
		foreach ( var part in document.Rig.VisibilityParts )
		{
			var visible = WeaponVisibilityEvaluator.Evaluate(
				part,
				clip,
				document.Workspace.TimelineTime );
			if ( part.RenderMode == VisibilityRenderMode.BodyGroup )
			{
				if ( string.IsNullOrWhiteSpace( part.BodyGroupName )
					|| !_sourceRenderer!.HasBodyGroups )
					continue;
				try
				{
					_sourceRenderer.SetBodyGroup(
						part.BodyGroupName,
						visible
							? part.VisibleBodyGroupValue
							: part.HiddenBodyGroupValue );
				}
				catch ( Exception ex )
				{
					Log.Warning(
						$"[Weapon Animator] preview bodygroup '{part.BodyGroupName}' failed: {ex.Message}" );
				}
				continue;
			}

			if ( !string.IsNullOrWhiteSpace( part.BodyGroupName )
				&& _sourceRenderer!.HasBodyGroups )
			{
				try
				{
					_sourceRenderer.SetBodyGroup(
						part.BodyGroupName,
						part.VisibleBodyGroupValue );
				}
				catch
				{
					// Switching back to bone mode should not leave the old bodygroup hidden.
				}
			}
			if ( visible || string.IsNullOrWhiteSpace( part.BoneName ) )
				continue;
			var root = _sourceRenderer!.Model.Bones.GetBone( part.BoneName );
			if ( root is null )
				continue;

			var collapsed = new Transform(
				Vector3.Down * 4000.0f,
				Rotation.Identity,
				Vector3.One * 0.001f );
			var queue = new Queue<BoneCollection.Bone>();
			queue.Enqueue( root );
			while ( queue.Count > 0 )
			{
				var bone = queue.Dequeue();
				_sourceRenderer.SetBoneTransform( bone, collapsed );
				foreach ( var child in bone.Children )
					queue.Enqueue( child );
			}
		}
	}

	private void ApplyArmPoseToRenderer( EvaluatedPose pose )
	{
		if ( !_armsRenderer.IsValid() )
			return;

		_armsRenderer!.BoneMergeTarget = null;
		_armsRenderer.WorldTransform = Transform.Zero;
		_armsRenderer.ClearPhysicsBones();
		foreach ( var bone in _armsRenderer.Model.Bones.AllBones )
		{
			if ( pose.Model.TryGetValue( bone.Name, out var modelTransform ) )
				_armsRenderer.SetBoneTransform( bone, modelTransform );
		}

		LogArmPoseDiagnostics( pose );
	}

	private void LogArmPoseDiagnostics( EvaluatedPose pose )
	{
		if ( _armPoseDiagnosticsLogged || !_armsRenderer.IsValid() )
			return;
		if ( ++_armPoseDiagnosticFrames < 3 )
			return;
		_armPoseDiagnosticsLogged = true;

		var compared = 0;
		var mismatches = 0;
		foreach ( var bone in _armsRenderer!.Model.Bones.AllBones )
		{
			if ( !pose.Model.TryGetValue( bone.Name, out var expected )
				|| !_armsRenderer.TryGetBoneTransform( bone, out var actual ) )
				continue;

			compared++;
			if ( WeaponPoseProjection.TransformNear( expected, actual, 0.001f ) )
				continue;
			mismatches++;
			if ( mismatches <= 4 )
			{
				Log.Warning(
					$"[Weapon Animator] arm pose mismatch '{bone.Name}': "
					+ $"expected={expected}, actual={actual}." );
			}
		}

		Log.Info(
			$"[Weapon Animator] arm pose bridge checked {compared} bones; "
			+ $"{mismatches} renderer override mismatches." );
	}

	private void LogSourcePoseDiagnostics( EvaluatedPose pose )
	{
		if ( _sourcePoseDiagnosticsLogged || !_sourceRenderer.IsValid() )
			return;
		if ( ++_sourcePoseDiagnosticFrames < 3 )
			return;
		_sourcePoseDiagnosticsLogged = true;

		var compared = 0;
		var mismatches = 0;
		var hiddenVisibilityBones = HiddenVisibilityBonesAtPlayhead();
		foreach ( var definition in _controller.Document.Rig.RetainedBones() )
		{
			if ( hiddenVisibilityBones.Contains( definition.Name ) )
				continue;
			var sourceBone = _sourceRenderer!.Model.Bones.GetBone( definition.Name );
			if ( sourceBone is null
				|| !WeaponPoseProjection.TryGetSourceWorldOverride(
					_controller.Document,
					pose,
					definition,
					out var expected )
				|| !_sourceRenderer.TryGetBoneTransform( sourceBone, out var actual ) )
				continue;

			compared++;
			var positionDelta = expected.Position.Distance( actual.Position );
			var rotationDelta = MathF.Max(
				(expected.Rotation.Forward - actual.Rotation.Forward).Length,
				(expected.Rotation.Up - actual.Rotation.Up).Length );
			var scaleDelta = (expected.Scale - actual.Scale).Length;
			if ( positionDelta <= 0.001f
				&& rotationDelta <= 0.001f
				&& scaleDelta <= 0.001f )
				continue;

			mismatches++;
			Log.Warning(
				$"[Weapon Animator] source pose mismatch '{definition.Name}': "
				+ $"position={positionDelta:0.######}, "
				+ $"rotation={rotationDelta:0.######}, "
				+ $"scale={scaleDelta:0.######}; "
				+ $"expected={expected}, actual={actual}." );
		}

		Log.Info(
			$"[Weapon Animator] source pose bridge checked {compared} retained bones; "
			+ $"{mismatches} renderer override mismatches." );
		if ( _hostSkeleton is not null
			&& _hostSkeleton.ByName.TryGetValue( "root", out var hostRoot )
			&& _hostSkeleton.ByName.TryGetValue( "weapon_root", out var weaponRoot )
			&& pose.Model.TryGetValue( "weapon_root", out var rootWorld )
			&& pose.Local.TryGetValue( "weapon_root", out var rootLocal ) )
		{
			Log.Info(
				$"[Weapon Animator] root bridge: hostRootBind={hostRoot.BindModelTransform}, "
				+ $"weaponRootBindModel={weaponRoot.BindModelTransform}, "
				+ $"weaponRootBindLocal={_hostSkeleton.GetBindLocal( weaponRoot )}, "
				+ $"poseRootWorld={rootWorld}, poseRootLocal={rootLocal}, "
				+ $"sourceRenderer={_sourceRenderer!.WorldTransform}." );
		}
	}

	private HashSet<string> HiddenVisibilityBonesAtPlayhead()
	{
		var document = _controller.Document;
		var clip = document.GetSelectedClip();
		var hidden = document.Rig.VisibilityParts
			.Where( x =>
				x.RenderMode == VisibilityRenderMode.BoneBranch
				&& !WeaponVisibilityEvaluator.Evaluate(
					x,
					clip,
					document.Workspace.TimelineTime ) )
			.Select( x => x.BoneName )
			.Where( x => !string.IsNullOrWhiteSpace( x ) )
			.ToHashSet( StringComparer.OrdinalIgnoreCase );
		if ( hidden.Count == 0 )
			return hidden;

		var changed = true;
		while ( changed )
		{
			changed = false;
			foreach ( var bone in document.Rig.RetainedBones() )
			{
				if ( hidden.Contains( bone.Name )
					|| !hidden.Contains( bone.ParentName ) )
					continue;
				hidden.Add( bone.Name );
				changed = true;
			}
		}
		return hidden;
	}

	private bool RepairLegacyIdleIfNeeded()
	{
		if ( _legacyIdleRepairVersionChecked == LegacyIdleRepairVersion
			|| _controller.Document.ActiveStage != WeaponAnimatorStage.Animate )
			return false;

		_legacyIdleRepairVersionChecked = LegacyIdleRepairVersion;
		var repaired = false;
		_controller.Mutate(
			"Repair generated Idle bind pose",
			document =>
			{
				var repairedLegacy = WeaponAnimationMigration.RepairLegacyIdleBindPose(
					document,
					_hostSkeleton );
				var repairedSelectionWrites = _hostSkeleton is not null
					&& IdleBindPoseService.RepairUnintendedSelectionWrites(
						document,
						_hostSkeleton );
				repaired = repairedLegacy || repairedSelectionWrites;
			} );
		if ( !repaired )
			return false;

		LegacyIdleRepaired?.Invoke();
		StatusChanged?.Invoke(
			"Restored the generated Idle clip to the current calibrated bind pose. "
			+ "A versioned backup will be created on save." );
		return true;
	}

	private void SuppressHostRendering()
	{
		if ( !_hostRenderer.IsValid() )
			return;

		// The host owns bones only. Its carrier mesh must never enter the authoring viewport.
		_hostRenderer!.Tint = Color.Transparent;
		_hostRenderer.SceneObject.RenderingEnabled = false;
	}

	private void OnSelectionChanged()
	{
		_occlusionCacheValid = false;
		_lastOcclusionDiagnostic = "";
		Update();
		if ( _controller.Document.ActiveStage != WeaponAnimatorStage.Animate )
			return;

		var selected = _controller.Document.Workspace.SelectedBone;
		if ( !selected.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase )
			|| selected.Equals( _lastDiagnosticSelection, StringComparison.OrdinalIgnoreCase ) )
			return;

		_lastDiagnosticSelection = selected;
		var clip = _controller.Document.GetSelectedClip();
		var rootTrack = clip?.Tracks.FirstOrDefault( x =>
			x.Target.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase ) );
		Log.Info(
			$"[Weapon Animator] Preview diagnostic: selected=weapon_root, "
			+ $"sourceModel={_loadedSource}, hostModel={_loadedHost}, "
			+ $"sourceScale={_sourceRenderer?.WorldTransform.Scale}, "
			+ $"hostRendering={_hostRenderer?.SceneObject.RenderingEnabled}, "
			+ $"rootKeys={rootTrack?.Keys.Count ?? 0}, "
			+ $"workingOverride={_controller.Document.Workspace.GetWorkingPose(
				clip?.Id ?? Guid.Empty,
				"weapon_root" ) is not null}." );
	}

	private void DrawRendererSkeleton(
		SkinnedModelRenderer? renderer,
		Color color,
		bool allowXray = false )
	{
		if ( !renderer.IsValid() || renderer!.Model is null )
			return;

		var style = SkeletonOverlayStyle.Resolve(
			allowXray && _controller.Document.Workspace.XRaySkeleton,
			1.0f );

		using ( Gizmo.Scope( "source_skeleton" ) )
		{
			Gizmo.Draw.IgnoreDepth = style.DrawThroughMeshes;
			DrawRendererSkeletonPass( renderer, color, 1.0f );
		}
	}

	private void DrawRendererSkeletonPass(
		SkinnedModelRenderer renderer,
		Color color,
		float alpha )
	{
		foreach ( var bone in renderer.Model.Bones.AllBones )
		{
			if ( !renderer.TryGetBoneTransform( bone, out var transform ) )
				continue;

			if ( bone.Parent is not null
				&& renderer.TryGetBoneTransform( bone.Parent, out var parent ) )
			{
				Gizmo.Draw.Color = color.WithAlpha( 0.55f * alpha );
				Gizmo.Draw.Line( parent.Position, transform.Position );
			}

			using var scope = Gizmo.Scope( $"source_bone:{bone.Name}", transform );
			var selected = bone.Name == _controller.Document.Workspace.SelectedBone;
			var radius = Math.Clamp( transform.Position.Distance( _camera.WorldPosition ) / 150.0f, 0.1f, 0.7f );
			Gizmo.Draw.Color = (selected ? Color.White : color).WithAlpha( alpha );
			Gizmo.Draw.SolidSphere(
				Vector3.Zero,
				selected ? radius * 0.7f : radius * 0.35f,
				6,
				4 );
			Gizmo.Hitbox.DepthBias = 0.01f;
			Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, radius ) );
			if ( Gizmo.IsHovered )
			{
				Gizmo.Draw.ScreenText( bone.Name, transform.Position, new Vector2( 10, -10 ) );
				if ( Gizmo.WasLeftMousePressed )
					_controller.SelectBone( bone.Name );
			}
		}
	}

	private void DrawHostSkeleton(
		EvaluatedPose pose,
		float alpha,
		bool useRenderedArms = false,
		bool allowXray = false )
	{
		if ( _hostSkeleton is null )
			return;

		EnsureBoneDepths();
		var style = SkeletonOverlayStyle.Resolve(
			allowXray && _controller.Document.Workspace.XRaySkeleton,
			alpha );

		var occludedBones = style.DrawThroughMeshes
			&& _controller.Document.Workspace.BoneOcclusionEnabled
			? ResolveOccludedBones( pose, useRenderedArms )
			: null;
		if ( occludedBones is { Count: > 0 } )
		{
			DrawMixedOcclusionLines(
				pose,
				useRenderedArms,
				occludedBones,
				style );
			using ( Gizmo.Scope( "host_skeleton_behind" ) )
			{
				Gizmo.Draw.IgnoreDepth = true;
				Gizmo.Draw.LineThickness = SkeletonOverlayStyle.OccludedLineThickness;
				DrawHostSkeletonPass(
					pose,
					style.OccludedAlpha,
					useRenderedArms,
					occluded: true,
					onlyBones: occludedBones,
					occlusionStates: occludedBones );
			}
		}

		using ( Gizmo.Scope( "host_skeleton" ) )
		{
			Gizmo.Draw.IgnoreDepth = style.DrawThroughMeshes;
			DrawHostSkeletonPass(
				pose,
				alpha,
				useRenderedArms,
				excludedBones: occludedBones,
				occlusionStates: occludedBones );
		}
	}

	private IReadOnlySet<string> ResolveOccludedBones(
		EvaluatedPose pose,
		bool useRenderedArms )
	{
		var samples = new List<(HostBone Bone, Transform Transform)>();
		var showIk = _controller.Document.Workspace.ShowIkBones;
		foreach ( var bone in _hostSkeleton!.Bones )
		{
			if ((SkeletonBoneStyle.Classify( bone ) == SkeletonBoneKind.Ik && !showIk)
				|| !TryGetDisplayedBoneTransform(
					pose,
					bone,
					useRenderedArms,
					out var transform ) )
				continue;

			samples.Add( (bone, transform) );
		}

		var cacheMatches = OcclusionCacheMatches( samples );
		if ( cacheMatches && !_occlusionFollowupPending )
			return _occludedBones;
		if ( !cacheMatches
			&& _occlusionCacheValid
			&& _sinceOcclusionTrace < (1.0f / 30.0f) )
			return _occludedBones;

		var completingFollowup = cacheMatches && _occlusionFollowupPending;
		_occlusionPose.Clear();
		_occludedBones.Clear();
		_occlusionCameraTransform = _camera.WorldTransform;
		_sinceOcclusionTrace = 0;
		foreach ( var sample in samples )
		{
			_occlusionPose[sample.Bone.Name] = sample.Transform;
			var occluded = IsBoneOccluded(
				sample.Bone,
				sample.Transform.Position,
				out var hitDescription );
			if ( occluded )
				_occludedBones.Add( sample.Bone.Name );

			if ( sample.Bone.Name.Equals(
				_controller.Document.Workspace.SelectedBone,
				StringComparison.OrdinalIgnoreCase ) )
				ReportOcclusionDiagnostic(
					sample.Bone,
					hitDescription,
					occluded );
		}

		_occlusionCacheValid = true;
		_occlusionFollowupPending = !completingFollowup;
		return _occludedBones;
	}

	private bool OcclusionCacheMatches(
		IReadOnlyList<(HostBone Bone, Transform Transform)> samples )
	{
		if ( !_occlusionCacheValid
			|| samples.Count != _occlusionPose.Count
			|| !WeaponPoseProjection.TransformNear(
				_occlusionCameraTransform,
				_camera.WorldTransform,
				0.0005f ) )
			return false;

		foreach ( var sample in samples )
		{
			if ( !_occlusionPose.TryGetValue( sample.Bone.Name, out var cached )
				|| !WeaponPoseProjection.TransformNear(
					cached,
					sample.Transform,
					0.0005f ) )
				return false;
		}

		return true;
	}

	private bool IsBoneOccluded(
		HostBone target,
		Vector3 targetPosition,
		out string description )
	{
		description = "none";
		if ( !Scene.IsValid()
			|| targetPosition.Distance( _camera.WorldPosition ) <= 0.001f )
			return false;

		var targetDistance = targetPosition.Distance( _camera.WorldPosition );
		var depthClearance = SkeletonOverlayStyle.OcclusionDepthClearance( targetDistance );
		var nearestDistance = float.MaxValue;
		var sameArmHits = 0;
		var oppositeArmHits = 0;
		var nearArmHits = 0;
		var unknownArmHits = 0;
		var sameArmExample = "";
		var unknownArmExample = "";

		var armTraces = Scene.Trace
			.Ray( _camera.WorldPosition, targetPosition )
			.WithTag( ArmsOccluderTag )
			.UseRenderMeshes( false )
			.UseHitboxes( true )
			.UsePhysicsWorld( false )
			.UseHitPosition( true )
			.RunAll();
		foreach ( var armTrace in armTraces )
		{
			var hitBoneName = ResolveArmHitBoneName( armTrace );
			var hitSide = !string.IsNullOrWhiteSpace( hitBoneName )
				&& _hostSkeleton!.ByName.TryGetValue( hitBoneName, out var hitBone )
					? hitBone.ArmSide
					: 0;
			if ( !SkeletonOcclusionPolicy.IsOccludedByArm(
				target.IsWeaponBone,
				target.ArmSide,
				hitSide ) )
			{
				if ( hitSide == target.ArmSide && hitSide != 0 )
				{
					sameArmHits++;
					if ( string.IsNullOrWhiteSpace( sameArmExample ) )
						sameArmExample = hitBoneName;
				}
				else
				{
					unknownArmHits++;
					if ( string.IsNullOrWhiteSpace( unknownArmExample ) )
						unknownArmExample = hitBoneName;
				}
				continue;
			}

			var gap = targetDistance - armTrace.Distance;
			if ( !SkeletonOverlayStyle.IsOccludingDepth(
				targetDistance,
				armTrace.Distance ) )
			{
				nearArmHits++;
				continue;
			}

			oppositeArmHits++;
			if ( armTrace.Distance >= nearestDistance )
				continue;

			nearestDistance = armTrace.Distance;
			description = string.IsNullOrWhiteSpace( hitBoneName )
				? $"arms hitbox (unknown bone) at {armTrace.Distance:0.###}"
				: $"arms hitbox ({hitBoneName}) at {armTrace.Distance:0.###}";
		}

		description +=
			$"; armHits=opposite:{oppositeArmHits},self:{sameArmHits}"
			+ $"({sameArmExample}),near:{nearArmHits},unknown:{unknownArmHits}"
			+ $"({unknownArmExample}); target={targetDistance:0.###},"
			+ $"clearance={depthClearance:0.###},weaponIgnored=True";
		return nearestDistance < float.MaxValue;
	}

	private string ResolveArmHitBoneName( SceneTraceResult trace )
	{
		var hitboxBoneName = trace.Hitbox?.Bone?.Name;
		if ( !string.IsNullOrWhiteSpace( hitboxBoneName ) )
			return hitboxBoneName;
		if ( trace.Bone >= 0 && _armsRenderer.IsValid() )
			return _armsRenderer!.Model.GetBoneName( trace.Bone );
		return "";
	}

	private void ReportOcclusionDiagnostic(
		HostBone target,
		string hitDescription,
		bool occluded )
	{
		var diagnostic = $"{target.Name}|{target.ArmSide}|{occluded}";
		if ( diagnostic.Equals( _lastOcclusionDiagnostic, StringComparison.Ordinal ) )
			return;

		_lastOcclusionDiagnostic = diagnostic;
		Log.Info(
			$"[Weapon Animator] X-ray diagnostic: target={target.Name}, "
			+ $"targetSide={target.ArmSide}, firstHit={hitDescription}, "
			+ $"reduced={occluded}." );
	}

	/// <summary>
	/// Bone depth drives the overlay gradient. <c>HostSkeleton.Bones</c> is topologically ordered,
	/// so one forward pass resolves every depth. <c>BuildCached</c> hands out a shared read-only
	/// instance, so the cache is keyed on that instance rather than storing depth on the bones.
	/// </summary>
	private void EnsureBoneDepths()
	{
		if ( ReferenceEquals( _boneDepthSource, _hostSkeleton ) )
			return;

		_boneDepthSource = _hostSkeleton;
		_boneDepths.Clear();
		_maxBoneDepth = 0;
		if ( _hostSkeleton is null )
			return;

		foreach ( var bone in _hostSkeleton.Bones )
		{
			var depth = !string.IsNullOrWhiteSpace( bone.ParentName )
				&& _boneDepths.TryGetValue( bone.ParentName, out var parentDepth )
					? parentDepth + 1
					: 0;
			_boneDepths[bone.Name] = depth;
			if ( depth > _maxBoneDepth
				&& SkeletonBoneStyle.Classify( bone ) == SkeletonBoneKind.Arm )
				_maxBoneDepth = depth;
		}
	}

	private void DrawHostSkeletonPass(
		EvaluatedPose pose,
		float alpha,
		bool useRenderedArms,
		bool occluded = false,
		IReadOnlySet<string>? onlyBones = null,
		IReadOnlySet<string>? excludedBones = null,
		IReadOnlySet<string>? occlusionStates = null )
	{
		var showIk = _controller.Document.Workspace.ShowIkBones;
		foreach ( var bone in _hostSkeleton!.Bones )
		{
			if ( onlyBones is not null && !onlyBones.Contains( bone.Name ) )
				continue;
			if ( excludedBones is not null && excludedBones.Contains( bone.Name ) )
				continue;

			if ( !TryGetDisplayedBoneTransform(
				pose,
				bone,
				useRenderedArms,
				out var transform ) )
				continue;

			var boneStyle = SkeletonBoneStyle.Resolve(
				SkeletonBoneStyle.Classify( bone ),
				_boneDepths.GetValueOrDefault( bone.Name ),
				_maxBoneDepth,
				showIk );
			// Skipping also drops the hitbox below, so hidden bones stop stealing clicks.
			if ( !boneStyle.Visible )
				continue;

			var color = occluded
				? SkeletonOverlayStyle.Occlude( boneStyle.Color )
				: boneStyle.Color;
			var boneAlpha = alpha * boneStyle.AlphaScale;
			var dotScale = occluded ? SkeletonOverlayStyle.OccludedDotScale : 1.0f;
			if ( !string.IsNullOrWhiteSpace( bone.ParentName )
				&& _hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone )
				&& TryGetDisplayedBoneTransform(
					pose,
					parentBone,
					useRenderedArms,
					out var parent ) )
			{
				var parentOccluded = occlusionStates?.Contains( parentBone.Name )
					?? occluded;
				if ( parentOccluded == occluded )
				{
					Gizmo.Draw.Color = color.WithAlpha( 0.45f * boneAlpha );
					Gizmo.Draw.Line( parent.Position, transform.Position );
				}
			}

			using var scope = Gizmo.Scope( $"host_bone:{bone.Name}", transform );
			var selected = bone.Name == _controller.Document.Workspace.SelectedBone;
			var radius = Math.Clamp( transform.Position.Distance( _camera.WorldPosition ) / 180.0f, 0.08f, 0.45f )
				* boneStyle.RadiusScale;
			Gizmo.Draw.Color = (selected ? Color.White : color).WithAlpha( boneAlpha );
			if ( boneStyle.Hollow )
				Gizmo.Draw.LineSphere( 0, (selected ? radius * 0.7f : radius * 0.45f) * dotScale, 3 );
			else
				Gizmo.Draw.SolidSphere( 0, (selected ? radius * 0.7f : radius * 0.3f) * dotScale, 5, 4 );
			Gizmo.Hitbox.Sphere( new Sphere( 0, radius ) );
			if ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )
				_controller.SelectBone( bone.Name );
		}
	}

	private void DrawMixedOcclusionLines(
		EvaluatedPose pose,
		bool useRenderedArms,
		IReadOnlySet<string> occludedBones,
		SkeletonOverlayStyle overlay )
	{
		var showIk = _controller.Document.Workspace.ShowIkBones;
		using var scope = Gizmo.Scope( "host_skeleton_occlusion_gradients" );
		Gizmo.Draw.IgnoreDepth = true;
		foreach ( var bone in _hostSkeleton!.Bones )
		{
			if ( string.IsNullOrWhiteSpace( bone.ParentName )
				|| !_hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone ) )
				continue;

			var boneOccluded = occludedBones.Contains( bone.Name );
			var parentOccluded = occludedBones.Contains( parentBone.Name );
			if ( boneOccluded == parentOccluded )
				continue;

			var boneStyle = SkeletonBoneStyle.Resolve(
				SkeletonBoneStyle.Classify( bone ),
				_boneDepths.GetValueOrDefault( bone.Name ),
				_maxBoneDepth,
				showIk );
			if ( !boneStyle.Visible )
				continue;

			var parentStyle = SkeletonBoneStyle.Resolve(
				SkeletonBoneStyle.Classify( parentBone ),
				_boneDepths.GetValueOrDefault( parentBone.Name ),
				_maxBoneDepth,
				showIk );
			if ( !parentStyle.Visible
				|| !TryGetDisplayedBoneTransform(
					pose,
					bone,
					useRenderedArms,
					out var boneTransform )
				|| !TryGetDisplayedBoneTransform(
					pose,
					parentBone,
					useRenderedArms,
					out var parentTransform ) )
				continue;

			var startVisual = overlay.ResolveLineVisual(
				parentStyle,
				parentOccluded );
			var endVisual = overlay.ResolveLineVisual(
				boneStyle,
				boneOccluded );
			var delta = boneTransform.Position - parentTransform.Position;
			for ( var segment = 0;
				segment < SkeletonOverlayStyle.OcclusionGradientSegments;
				segment++ )
			{
				var startFraction =
					segment / (float)SkeletonOverlayStyle.OcclusionGradientSegments;
				var endFraction =
					(segment + 1) / (float)SkeletonOverlayStyle.OcclusionGradientSegments;
				var visual = SkeletonLineVisual.Lerp(
					startVisual,
					endVisual,
					(startFraction + endFraction) * 0.5f );
				Gizmo.Draw.Color = visual.Color;
				Gizmo.Draw.LineThickness = visual.Thickness;
				Gizmo.Draw.Line(
					parentTransform.Position + (delta * startFraction),
					parentTransform.Position + (delta * endFraction) );
			}
		}
	}

	private bool TryGetDisplayedBoneTransform(
		EvaluatedPose pose,
		HostBone bone,
		bool useRenderedArms,
		out Transform transform )
	{
		if ( useRenderedArms && !bone.IsWeaponBone && _armsRenderer.IsValid() )
		{
			var rendererBone = _armsRenderer!.Model.Bones.GetBone( bone.Name );
			if ( rendererBone is not null
				&& _armsRenderer.TryGetBoneTransform( rendererBone, out transform ) )
				return true;
		}

		return pose.Model.TryGetValue( bone.Name, out transform );
	}

	private void DrawOnionSkins( WeaponAnimationClip clip )
	{
		if ( _hostSkeleton is null )
			return;
		var step = 1.0f / MathF.Max( clip.SampleRate, 1 );

		// Onion skins are context only. They share bone names with the live skeleton, so leaving
		// them interactive would put duplicate hit targets on neighbouring frames.
		using var scope = Gizmo.Scope( "onion_skins" );
		Gizmo.Hitbox.CanInteract = false;
		foreach ( var offset in new[] { -step, step } )
		{
			var time = Math.Clamp(
				_controller.Document.Workspace.TimelineTime + offset,
				0,
				clip.Duration );
			var onion = AnimationPoseEvaluator.Evaluate(
				_controller.Document,
				_hostSkeleton,
				clip,
				time );
			DrawHostSkeleton( onion, 0.18f );
		}
	}

	private void DrawGripTethers( EvaluatedPose pose )
	{
		if ( _controller.Document.Binding.PrimaryHand.IsBound )
		{
			DrawTether(
				_controller.Document.Binding.PrimaryHand,
				pose,
				pose.PrimaryHandGoal,
				_controller.Document.Binding.PrimaryHand.Reachable,
				"hand_R" );
		}
		if ( _controller.Document.Binding.Configuration == GripConfiguration.TwoHanded )
		{
			if ( _controller.Document.Binding.SupportHand.IsBound )
			{
				DrawTether(
					_controller.Document.Binding.SupportHand,
					pose,
					pose.SupportHandGoal,
					_controller.Document.Binding.SupportHand.Reachable,
					"hand_L" );
			}
		}
	}

	private static void DrawTether(
		RigTarget target,
		EvaluatedPose pose,
		Transform? solvedGoal,
		bool reachable,
		string handBone )
	{
		if ( !pose.Model.TryGetValue( handBone, out var hand ) )
			return;

		// Draw the goal the IK actually solved toward. The raw binding transform is the bind-time
		// value, so it drifts away from the hand as soon as a clip animates the control - which made
		// the tether read as "far out of reach" while the hand sat correctly on the weapon.
		Transform targetTransform;
		if ( solvedGoal is { } goal )
		{
			targetTransform = goal;
		}
		else
		{
			targetTransform = target.Transform;
			if ( !string.IsNullOrWhiteSpace( target.AttachedBone )
				&& pose.Model.TryGetValue( target.AttachedBone, out var attached ) )
			{
				targetTransform = new Transform(
					attached.PointToWorld( target.Transform.Position ),
					attached.Rotation * target.Transform.Rotation );
			}
		}

		// Scoped so the colour and thickness do not leak into whatever draws next.
		using var scope = Gizmo.Scope( "grip_tether" );
		Gizmo.Draw.Color = reachable ? WeaponAnimatorTheme.Green : WeaponAnimatorTheme.Coral;
		Gizmo.Draw.LineThickness = 2.5f;
		Gizmo.Draw.Line( hand.Position, targetTransform.Position );
		Gizmo.Draw.SolidSphere( targetTransform.Position, 0.18f, 8, 6 );
	}

	private void DrawSelectedAnchorControl( WeaponAnchor anchor )
	{
		if ( !_sourceRenderer.IsValid() )
			return;

		var sourceTransform = _sourceRenderer!.WorldTransform;
		var liveWorld = new Transform(
			sourceTransform.PointToWorld( anchor.LocalPosition ),
			sourceTransform.Rotation * anchor.LocalRotation );
		var token = $"anchor:{anchor.Kind}";
		var dragging = IsCalibrationDrag( token );
		var startWorld = dragging ? _calibrationGizmoStartWorld : liveWorld;
		var startLocal = dragging
			? _calibrationGizmoStartLocal
			: new Transform( anchor.LocalPosition, anchor.LocalRotation );
		var basis = CalibrationGizmoBasis( startWorld );

		// Scale is deliberately dropped from the scope. Feeding the gizmo a scaled transform
		// resizes its handles by the calibration scale, and feeding it a rotated one makes the
		// handles point along the rig's local axes while the result is applied in world space.
		using var scope = Gizmo.Scope(
			$"anchor_control:{anchor.Kind}",
			new Transform( startWorld.Position, basis ) );
		Gizmo.Draw.Color = AnchorColor( anchor.Kind );
		Gizmo.Draw.LineSphere( new Sphere( Vector3.Zero, 0.24f ) );

		if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
		{
			if ( Gizmo.Control.Rotate( "anchor_rotate", Rotation.Identity, out var delta ) )
			{
				BeginCalibrationGizmoDrag(
					token,
					$"Rotate {anchor.Name} anchor",
					liveWorld,
					new Transform( anchor.LocalPosition, anchor.LocalRotation ) );
				// Rotate reports the total rotation since the grab, so it applies to the start.
				var snapped = SnapRotation( delta );
				var rotation = _controller.Document.Workspace.LocalGizmos
					? (startLocal.Rotation * snapped).Normal
					: (sourceTransform.Rotation.Inverse
						* snapped
						* sourceTransform.Rotation
						* startLocal.Rotation).Normal;
				_controller.UpdateContinuousEdit( document =>
				{
					var selected = document.Calibration.FindAnchor( anchor.Id );
					if ( selected is null )
						return;
					selected.LocalRotation = rotation;
					document.Calibration.Confirmed = false;
				} );
			}
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Move
			&& Gizmo.Control.Position( "anchor_move", Vector3.Zero, out var moveDelta, basis ) )
		{
			BeginCalibrationGizmoDrag(
				token,
				$"Move {anchor.Name} anchor",
				liveWorld,
				new Transform( anchor.LocalPosition, anchor.LocalRotation ) );
			_calibrationGizmoMoveDelta += moveDelta;
			var world = SnapPositionDelta(
				_calibrationGizmoStartWorld.Position,
				_calibrationGizmoMoveDelta,
				basis );
			var local = sourceTransform.PointToLocal( world );
			_controller.UpdateContinuousEdit( document =>
			{
				var selected = document.Calibration.FindAnchor( anchor.Id );
				if ( selected is null )
					return;
				selected.LocalPosition = local;
				document.Calibration.Confirmed = false;
			} );
		}
	}

	private void DrawWholeRigControl()
	{
		var document = _controller.Document;
		var framing = document.Workspace.FirstPersonPreview;
		var live = framing
			? document.Calibration.FramingTransform
			: document.Calibration.PhysicalTransform;
		var token = framing ? "rig:framing" : "rig:physical";
		var dragging = IsCalibrationDrag( token );
		var start = dragging ? _calibrationGizmoStartWorld : live;
		var basis = CalibrationGizmoBasis( start );

		using var scope = Gizmo.Scope(
			"whole_rig",
			new Transform( start.Position, basis ) );
		Gizmo.Draw.Color = WeaponAnimatorTheme.Amber;
		Gizmo.Draw.LineSphere( new Sphere( Vector3.Zero, 0.3f ) );

		if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
		{
			if ( Gizmo.Control.Rotate( "rig_rotate", Rotation.Identity, out var delta ) )
			{
				BeginCalibrationGizmoDrag( token, "Refine whole-rig rotation", live, live );
				var snapped = SnapRotation( delta );
				var rotation = document.Workspace.LocalGizmos
					? (_calibrationGizmoStartWorld.Rotation * snapped).Normal
					: (snapped * _calibrationGizmoStartWorld.Rotation).Normal;
				_controller.UpdateContinuousEdit( d =>
					SetRigTransform( d, framing, target => target.WithRotation( rotation ) ) );
			}
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Move
			&& Gizmo.Control.Position( "rig_move", Vector3.Zero, out var moveDelta, basis ) )
		{
			BeginCalibrationGizmoDrag( token, "Refine whole-rig position", live, live );
			_calibrationGizmoMoveDelta += moveDelta;
			var position = SnapPositionDelta(
				_calibrationGizmoStartWorld.Position,
				_calibrationGizmoMoveDelta,
				basis );
			_controller.UpdateContinuousEdit( d =>
				SetRigTransform( d, framing, target => target.WithPosition( position ) ) );
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Scale
			&& Gizmo.Control.Scale( "rig_scale", Vector3.Zero, out var scaleDelta, basis ) )
		{
			BeginCalibrationGizmoDrag( token, "Refine whole-rig scale", live, live );
			_calibrationGizmoScaleDelta += scaleDelta / 0.01f;
			// Rig scale is uniform, so respond to whichever handle is being dragged rather than
			// only the X axis. The uniform centre handle reports all three equally.
			var dominant = DominantAxis( _calibrationGizmoScaleDelta );
			var factor = MathF.Max(
				1.0f + dominant * ScaleGizmoSensitivity,
				0.0001f );
			var uniform = MathF.Max(
				_calibrationGizmoStartWorld.Scale.x * factor,
				0.0001f );
			_controller.UpdateContinuousEdit( d =>
			{
				SetRigTransform( d, framing, target => target.WithScale( uniform ) );
				if ( !framing )
					d.Calibration.UniformScale = uniform;
			} );
		}
	}

	internal static float DominantAxis( Vector3 value )
	{
		var dominant = value.x;
		if ( MathF.Abs( value.y ) > MathF.Abs( dominant ) )
			dominant = value.y;
		if ( MathF.Abs( value.z ) > MathF.Abs( dominant ) )
			dominant = value.z;
		return dominant;
	}

	// The whole rig and its anchors are authored in world space unless Local is toggled on.
	private Rotation CalibrationGizmoBasis( Transform start ) =>
		_controller.Document.Workspace.LocalGizmos
			? start.Rotation
			: Rotation.Identity;

	private static void SetRigTransform(
		WeaponAnimationDocument document,
		bool framing,
		Func<Transform, Transform> edit )
	{
		if ( framing )
		{
			document.Calibration.FramingTransform =
				edit( document.Calibration.FramingTransform );
			return;
		}

		document.Calibration.PhysicalTransform =
			edit( document.Calibration.PhysicalTransform );
		document.Calibration.Confirmed = false;
	}

	private bool IsCalibrationDrag( string target ) =>
		_calibrationGizmoTarget.Equals( target, StringComparison.Ordinal );

	// Calibration gizmos accumulate into one undo entry, matching the animation gizmos below.
	private void BeginCalibrationGizmoDrag(
		string target,
		string description,
		Transform startWorld,
		Transform startLocal )
	{
		// Reopen if an unrelated mutation closed our continuous edit mid-drag, otherwise
		// UpdateContinuousEdit silently discards the rest of the drag.
		if ( IsCalibrationDrag( target ) && _controller.IsContinuousEditActive )
			return;

		EndCalibrationGizmoDrag();
		_calibrationGizmoTarget = target;
		_calibrationGizmoStartWorld = startWorld;
		_calibrationGizmoStartLocal = startLocal;
		_calibrationGizmoMoveDelta = Vector3.Zero;
		_calibrationGizmoScaleDelta = Vector3.Zero;
		_controller.BeginContinuousEdit( description );
	}

	private void FinishCalibrationGizmoDragIfReleased()
	{
		if ( string.IsNullOrEmpty( _calibrationGizmoTarget ) )
			return;

		// Requires both signals. Gizmo.Pressed can read false between frames while the mouse is
		// still held, and ending on that alone splits one drag into an undo entry per frame.
		if ( Gizmo.Pressed.Any
			|| global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Left ) )
			return;

		EndCalibrationGizmoDrag();
	}

	private void EndCalibrationGizmoDrag()
	{
		if ( string.IsNullOrEmpty( _calibrationGizmoTarget ) )
			return;

		_calibrationGizmoTarget = "";
		_calibrationGizmoMoveDelta = Vector3.Zero;
		_calibrationGizmoScaleDelta = Vector3.Zero;
		_controller.EndContinuousEdit();
	}

	private void DrawAnimationControl()
	{
		var context = SelectionTransformContext.Resolve( _controller );
		if ( context is null )
			return;

		var dragging = _animationGizmoTarget.Equals(
			context.Target,
			StringComparison.OrdinalIgnoreCase );
		var startWorld = dragging ? _animationGizmoStartWorld : context.WorldTransform;
		var basis = context.LocalSpace
			? startWorld.Rotation
			: Rotation.Identity;
		var gizmoTransform = new Transform( startWorld.Position, basis );
		using var scope = Gizmo.Scope( $"animate:{context.Target}", gizmoTransform );
		Gizmo.Draw.Color = context.Kind == RigControlKind.Weapon
			? WeaponAnimatorTheme.Amber
			: WeaponAnimatorTheme.Cyan;
		Gizmo.Draw.LineSphere( new Sphere( 0, 0.25f ) );

		if ( TransformMode == WeaponAnimatorTransformMode.Rotate )
		{
			if ( Gizmo.Control.Rotate( "rotate", Rotation.Identity, out var delta ) )
			{
				BeginAnimationGizmoDrag( context );
				var snapped = SnapRotation( delta );
				var local = _animationGizmoStartLocal;
				if ( context.LocalSpace )
				{
					local.Rotation = (_animationGizmoStartLocal.Rotation * snapped).Normal;
				}
				else
				{
					var editedWorld = _animationGizmoStartWorld.WithRotation(
						(snapped * _animationGizmoStartWorld.Rotation).Normal );
					local = WorldToLocal( editedWorld, _animationGizmoStartParent );
				}
				_controller.UpdateTransformEditContinuous(
					context.Target,
					context.Kind,
					local );
			}
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Move
			&& Gizmo.Control.Position( "move", Vector3.Zero, out var delta, basis ) )
		{
			BeginAnimationGizmoDrag( context );
			_animationGizmoMoveDelta += delta;
			var position = SnapPositionDelta(
				_animationGizmoStartWorld.Position,
				_animationGizmoMoveDelta,
				basis );
			var editedWorld = _animationGizmoStartWorld.WithPosition( position );
			var local = WorldToLocal( editedWorld, _animationGizmoStartParent );
			_controller.UpdateTransformEditContinuous(
				context.Target,
				context.Kind,
				local );
		}
		else if ( TransformMode == WeaponAnimatorTransformMode.Scale
			&& Gizmo.Control.Scale( "scale", Vector3.Zero, out var scaleDelta, basis ) )
		{
			BeginAnimationGizmoDrag( context );
			_animationGizmoScaleDelta += scaleDelta / 0.01f;
			var local = ScaleFromStart(
				_animationGizmoStartLocal,
				_animationGizmoStartWorld,
				_animationGizmoStartParent,
				context.LocalSpace,
				_animationGizmoScaleDelta );
			_controller.UpdateTransformEditContinuous(
				context.Target,
				context.Kind,
				local );
		}
	}

	private void BeginAnimationGizmoDrag( SelectionTransformContext context )
	{
		if ( _animationGizmoTarget.Equals(
			context.Target,
			StringComparison.OrdinalIgnoreCase )
			&& _animationGizmoKind == context.Kind )
			return;

		EndAnimationGizmoDrag();
		_animationGizmoTarget = context.Target;
		_animationGizmoKind = context.Kind;
		_animationGizmoStartLocal = context.LocalTransform;
		_animationGizmoStartWorld = context.WorldTransform;
		_animationGizmoStartParent = context.ParentTransform;
		_animationGizmoMoveDelta = Vector3.Zero;
		_animationGizmoScaleDelta = Vector3.Zero;
		_controller.BeginContinuousEdit(
			$"{TransformModeName( TransformMode )} {context.Target}" );
	}

	private void FinishAnimationGizmoDragIfReleased()
	{
		if ( string.IsNullOrWhiteSpace( _animationGizmoTarget )
			|| Gizmo.Pressed.Any )
			return;

		EndAnimationGizmoDrag();
	}

	private void EndAnimationGizmoDrag()
	{
		if ( string.IsNullOrWhiteSpace( _animationGizmoTarget ) )
			return;

		_animationGizmoTarget = "";
		_animationGizmoMoveDelta = Vector3.Zero;
		_animationGizmoScaleDelta = Vector3.Zero;
		_animationGizmoStartParent = null;
		_controller.EndContinuousEdit();
	}

	private Rotation SnapRotation( Rotation delta ) =>
		_controller.Document.Workspace.SnapRotation ? Gizmo.Snap( delta ) : delta;

	private Vector3 SnapPositionDelta( Vector3 start, Vector3 movement, Rotation localSpace )
	{
		if ( !_controller.Document.Workspace.SnapPosition )
			return start + movement;

		return Gizmo.Snap( start, movement, localSpace );
	}

	internal static Transform WorldToLocal( Transform world, Transform? parent ) =>
		parent is null ? world : parent.Value.ToLocal( world );

	internal static Transform ScaleFromStart(
		Transform startLocal,
		Transform startWorld,
		Transform? parent,
		bool localSpace,
		Vector3 accumulatedDelta )
	{
		var factor = ClampScale(
			Vector3.One + accumulatedDelta * ScaleGizmoSensitivity );
		if ( localSpace )
			return startLocal.WithScale(
				ClampScale( startLocal.Scale * factor ) );

		var editedWorld = startWorld.WithScale(
			ClampScale( startWorld.Scale * factor ) );
		return WorldToLocal( editedWorld, parent );
	}

	private static Vector3 ClampScale( Vector3 scale ) =>
		new(
			MathF.Max( scale.x, 0.0001f ),
			MathF.Max( scale.y, 0.0001f ),
			MathF.Max( scale.z, 0.0001f ) );

	private void DrawMeasurement()
	{
		var measurement = _controller.Document.Calibration.Measurement;
		if ( !measurement.HasFirstPoint )
			return;

		var transform = _sourceRenderer?.WorldTransform ?? Transform.Zero;
		var a = transform.PointToWorld( measurement.FirstPoint );
		Gizmo.Draw.Color = WeaponAnimatorTheme.Cyan;
		Gizmo.Draw.SolidSphere( a, 0.15f, 8, 6 );
		if ( !measurement.HasSecondPoint )
			return;

		var b = transform.PointToWorld( measurement.SecondPoint );
		Gizmo.Draw.SolidSphere( b, 0.15f, 8, 6 );
		Gizmo.Draw.LineThickness = 2;
		Gizmo.Draw.Line( a, b );
		Gizmo.Draw.ScreenText(
			$"{measurement.FirstPoint.Distance( measurement.SecondPoint ):0.###} source units",
			(a + b) * 0.5f,
			new Vector2( 8, -8 ) );
	}

	private void DrawAnchors()
	{
		var transform = _sourceRenderer?.WorldTransform ?? Transform.Zero;
		foreach ( var anchor in _controller.Document.Calibration.Anchors )
		{
			var world = transform.PointToWorld( anchor.LocalPosition );
			var color = AnchorColor( anchor.Kind );
			var markerScale = Math.Clamp( world.Distance( _camera.WorldPosition ) / 75.0f, 0.45f, 1.4f );
			var labelOffset = AnchorLabelOffset( anchor.Kind );
			var leaderEnd = world
				+ _camera.WorldRotation.Right * labelOffset.x * markerScale
				+ _camera.WorldRotation.Up * labelOffset.y * markerScale;
			Gizmo.Draw.Color = color.WithAlpha( 0.75f );
			Gizmo.Draw.LineThickness = 1.5f;
			Gizmo.Draw.Line( world, leaderEnd );
			Gizmo.Draw.ScreenText(
				$"[{AnchorCode( anchor.Kind )}] {CalibrationSelection.DisplayName( anchor ).ToUpperInvariant()}",
				leaderEnd,
				new Vector2( 6, -6 ),
				size: 11 );
			var token = CalibrationSelection.Anchor( anchor );
			using var scope = Gizmo.Scope(
				$"anchor:{anchor.Id:N}",
				new Transform( world, transform.Rotation * anchor.LocalRotation ) );
			var selected = _controller.Document.Workspace.SelectedControl == token;
			Gizmo.Draw.Color = selected ? Color.White : color;
			Gizmo.Draw.SolidSphere( Vector3.Zero, selected ? 0.24f : 0.18f, 8, 6 );
			Gizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, 0.32f ) );
			if ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )
				_controller.SelectControl( token );
		}

		var rear = _controller.Document.Calibration.GetAnchor( AnchorKind.RearBore );
		var front = _controller.Document.Calibration.GetAnchor( AnchorKind.FrontBore );
		if ( rear is null || front is null )
			return;
		Gizmo.Draw.Color = WeaponAnimatorTheme.Amber;
		Gizmo.Draw.LineThickness = 2;
		Gizmo.Draw.Arrow(
			transform.PointToWorld( rear.LocalPosition ),
			transform.PointToWorld( front.LocalPosition ),
			0.6f,
			0.25f );
	}

	private void DrawScreenGuides()
	{
		var document = _controller.Document;
		if ( !document.Workspace.ShowGuides )
			return;

		var viewport = new Rect( 0, 0, Size.x, Size.y );
		var guideAspect = GuideAspect( document.Calibration.AspectGuide );
		var viewportAspect = Size.x / MathF.Max( Size.y, 1 );
		Rect guide;
		if ( viewportAspect > guideAspect )
		{
			var width = Size.y * guideAspect;
			guide = new Rect( (Size.x - width) * 0.5f, 0, width, Size.y );
		}
		else
		{
			var height = Size.x / guideAspect;
			guide = new Rect( 0, (Size.y - height) * 0.5f, Size.x, height );
		}

		Gizmo.Draw.ScreenRect(
			viewport,
			Color.Transparent,
			borderColor: Color.White.WithAlpha( 0.05f ),
			borderSize: new Vector4( 1 ) );
		Gizmo.Draw.ScreenRect(
			guide,
			Color.Transparent,
			borderColor: Color.White.WithAlpha( 0.35f ),
			borderSize: new Vector4( 1 ) );

		if ( document.Calibration.ShowSafeArea )
		{
			Gizmo.Draw.ScreenRect(
				guide.Shrink( guide.Width * 0.05f, guide.Height * 0.05f ),
				Color.Transparent,
				borderColor: WeaponAnimatorTheme.Cyan.WithAlpha( 0.24f ),
				borderSize: new Vector4( 1 ) );
		}

		if ( document.Calibration.ShowCrosshair )
		{
			Gizmo.Draw.Color = Color.White.WithAlpha( 0.65f );
			Gizmo.Draw.ScreenText( "+", guide.Center, size: 19, flags: TextFlag.Center );
		}

		Gizmo.Draw.Color = WeaponAnimatorTheme.Muted;
		var mode = document.Workspace.FirstPersonPreview
			? "VIEWMODEL CAMERA"
			: document.Workspace.FreeLookCamera
				? "FREE LOOK"
				: "ORBIT";
		Gizmo.Draw.ScreenText(
			$"{mode} · {document.Calibration.AspectGuide} · {document.Calibration.HorizontalFov:0}° HFOV",
			new Vector2( 12, 52 ),
			size: 10 );
	}

	private void DrawViewportToolReadout()
	{
		Gizmo.Draw.ScreenRect(
			new Rect( 109, 15, 1, 18 ),
			Color.White.WithAlpha( 0.14f ) );
		Gizmo.Draw.ScreenRect(
			new Rect( 157, 15, 1, 18 ),
			Color.White.WithAlpha( 0.14f ) );
		Gizmo.Draw.ScreenRect(
			new Rect( MathF.Max( Width - 47, 66 ), 15, 1, 18 ),
			Color.White.WithAlpha( 0.14f ) );
		Gizmo.Draw.ScreenRect(
			TransformReadoutRect,
			WeaponAnimatorTheme.Background.WithAlpha( 0.25f ) );
		var text = new TextRendering.Scope
		{
			Text = _transformModeText,
			TextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.78f ),
			FontSize = 10 * global::Editor.Application.DpiScale,
			FontName = "Inter",
			FontWeight = 500,
			LineHeight = 1
		};
		Gizmo.Draw.ScreenText(
			text,
			new Vector2(
				TransformReadoutRect.Left + 6,
				TransformReadoutRect.Center.y ),
			TextFlag.LeftCenter );
	}

	private void DrawCameraSpeedOverlay()
	{
		if ( _sinceCameraSpeedChanged >= 1.8f )
			return;

		var elapsed = (float)_sinceCameraSpeedChanged;
		var alpha = elapsed <= 0.9f
			? 1.0f
			: 1.0f - Math.Clamp( (elapsed - 0.9f) / 0.9f, 0, 1 );
		var rect = new Rect(
			MathF.Max( (Width - 150) * 0.5f, 0 ),
			Width >= 720 ? 10 : Width >= 620 ? 46 : 82,
			150,
			28 );
		Gizmo.Draw.ScreenRect(
			rect,
			WeaponAnimatorTheme.Background.WithAlpha( 0.55f * alpha ) );
		var text = new TextRendering.Scope
		{
			Text = $"CAMERA SPEED  {_controller.Document.Workspace.CameraMoveSpeed:0.##}×",
			TextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.9f * alpha ),
			FontSize = 10 * global::Editor.Application.DpiScale,
			FontName = "Inter",
			FontWeight = 500,
			LineHeight = 1
		};
		Gizmo.Draw.ScreenText( text, rect.Center, TextFlag.Center );
	}

	private bool TryPickSourceSurface( Vector2 localPosition, out Vector3 modelPosition )
	{
		modelPosition = default;
		if ( !_sourceRenderer.IsValid() || _sourceRenderer!.Model is null )
			return false;

		var ray = GetRay( localPosition );
		var localRay = ray.ToLocal( _sourceRenderer.WorldTransform );
		var trace = _sourceRenderer.Model.Trace.Ray( localRay, 8192 ).Run();
		if ( !trace.Hit )
			return false;

		modelPosition = trace.HitPosition;
		return true;
	}

	private void ApplyPickedPoint( Vector3 localPosition )
	{
		var mode = PickMode;
		var anchorId = PickAnchorId;
		PickMode = ViewportPickMode.None;
		PickAnchorId = default;
		var token = "";
		_controller.Mutate( $"Set {PickLabel( mode )}", document =>
		{
			var measurement = document.Calibration.Measurement;
			switch ( mode )
			{
				case ViewportPickMode.MeasurementFirst:
					measurement.FirstPoint = localPosition;
					measurement.HasFirstPoint = true;
					break;
				case ViewportPickMode.MeasurementSecond:
					measurement.SecondPoint = localPosition;
					measurement.HasSecondPoint = true;
					break;
				case ViewportPickMode.CustomAnchor:
					// Placing an existing custom anchor must not disturb its stored attachment name.
					if ( document.Calibration.FindAnchor( anchorId ) is not { } custom )
						break;
					custom.BoneName = document.Workspace.SelectedBone;
					custom.LocalPosition = localPosition;
					document.Calibration.Confirmed = false;
					token = CalibrationSelection.Anchor( custom );
					break;
				default:
					var kind = PickAnchorKind( mode );
					document.Calibration.SetAnchor( new WeaponAnchor
					{
						Kind = kind,
						Name = CalibrationSelection.DisplayName( kind ),
						BoneName = document.Workspace.SelectedBone,
						LocalPosition = localPosition
					} );
					document.Calibration.Confirmed = false;
					token = CalibrationSelection.Anchor( kind );
					break;
			}
		} );
		if ( !string.IsNullOrEmpty( token ) )
			_controller.SelectControl( token );
		StatusChanged?.Invoke( $"{PickLabel( mode )} set at {localPosition}." );
	}

	private static AnchorKind PickAnchorKind( ViewportPickMode mode ) => mode switch
	{
		ViewportPickMode.GripAnchor => AnchorKind.Grip,
		ViewportPickMode.RearBoreAnchor => AnchorKind.RearBore,
		ViewportPickMode.FrontBoreAnchor => AnchorKind.FrontBore,
		ViewportPickMode.MuzzleAnchor => AnchorKind.Muzzle,
		ViewportPickMode.EjectAnchor => AnchorKind.Eject,
		_ => AnchorKind.Custom
	};

	private static string PickLabel( ViewportPickMode mode ) => mode switch
	{
		ViewportPickMode.MeasurementFirst => "measurement point A",
		ViewportPickMode.MeasurementSecond => "measurement point B",
		ViewportPickMode.GripAnchor => "primary grip",
		ViewportPickMode.RearBoreAnchor => "alignment marker — rear",
		ViewportPickMode.FrontBoreAnchor => "alignment marker — front",
		ViewportPickMode.MuzzleAnchor => "muzzle",
		ViewportPickMode.EjectAnchor => "eject",
		ViewportPickMode.CustomAnchor => "custom anchor",
		_ => "point"
	};

	private static Color AnchorColor( AnchorKind kind ) => kind switch
	{
		AnchorKind.Grip => WeaponAnimatorTheme.Cyan,
		AnchorKind.RearBore => new Color( 0.64f, 0.48f, 0.95f ),
		AnchorKind.FrontBore => WeaponAnimatorTheme.Amber,
		AnchorKind.Muzzle => WeaponAnimatorTheme.Coral,
		AnchorKind.Eject => WeaponAnimatorTheme.Green,
		_ => Color.White
	};

	private static Vector2 AnchorLabelOffset( AnchorKind kind ) => kind switch
	{
		AnchorKind.Grip => new Vector2( -1.8f, 1.1f ),
		AnchorKind.RearBore => new Vector2( 1.5f, 1.7f ),
		AnchorKind.FrontBore => new Vector2( 1.7f, 0.8f ),
		AnchorKind.Muzzle => new Vector2( 2.1f, -0.5f ),
		AnchorKind.Eject => new Vector2( -1.7f, 1.8f ),
		_ => new Vector2( 1.5f, 1.0f )
	};

	private static string AnchorCode( AnchorKind kind ) => kind switch
	{
		AnchorKind.Grip => "G",
		AnchorKind.RearBore => "AR",
		AnchorKind.FrontBore => "AF",
		AnchorKind.Muzzle => "M",
		AnchorKind.Eject => "E",
		_ => "A"
	};

	private static float GuideAspect( string guide ) => guide switch
	{
		"4:3" => 4.0f / 3.0f,
		"21:9" => 21.0f / 9.0f,
		_ => 16.0f / 9.0f
	};
}
sonac.sbox-animator / Code/Runtime/WeaponVisibilityEvaluator.cs
Game library
#nullable enable annotations

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

namespace SboxWeaponAnimator;

public readonly record struct WeaponVisibilitySpan(
	string Name,
	float StartTime,
	float EndTime,
	bool Visible );

public static class WeaponVisibilityEvaluator
{
	private const float TimeTolerance = 0.0001f;

	public static bool Evaluate(
		WeaponVisibilityPart part,
		WeaponAnimationClip? clip,
		float time )
	{
		var track = clip?.VisibilityTracks.FirstOrDefault( x =>
			x.PartId == part.Id && !x.Muted );
		if ( track is null )
			return part.DefaultVisible;

		var result = part.DefaultVisible;
		foreach ( var key in track.Keys )
		{
			if ( key.Time > time + TimeTolerance )
				break;
			result = key.Visible;
		}
		return result;
	}

	public static VisibilityKey UpsertKey(
		VisibilityTrack track,
		float time,
		bool visible )
	{
		var key = track.Keys.FirstOrDefault( x =>
			MathF.Abs( x.Time - time ) <= TimeTolerance );
		if ( key is null )
		{
			key = new VisibilityKey { Time = time };
			track.Keys.Add( key );
		}

		key.Visible = visible;
		track.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
		return key;
	}

	public static string VisibleTag( Guid partId ) =>
		$"wepanim_part_{partId:N}_visible";

	public static string HiddenTag( Guid partId ) =>
		$"wepanim_part_{partId:N}_hidden";

	public static IReadOnlyList<WeaponVisibilitySpan> BuildSpans(
		WeaponVisibilityPart part,
		WeaponAnimationClip clip )
	{
		var duration = MathF.Max( clip.Duration, TimeTolerance );
		var transitions = clip.VisibilityTracks
			.FirstOrDefault( x => x.PartId == part.Id && !x.Muted )?
			.Keys
			.Where( x => x.Time >= 0 && x.Time <= duration + TimeTolerance )
			.OrderBy( x => x.Time )
			.ToArray() ?? [];
		var result = new List<WeaponVisibilitySpan>();
		var state = part.DefaultVisible;
		var start = 0.0f;

		foreach ( var key in transitions )
		{
			var time = Math.Clamp( key.Time, 0, duration );
			if ( key.Visible == state )
				continue;

			if ( time > start + TimeTolerance )
				result.Add( Span( part, start, time, state ) );
			state = key.Visible;
			start = time;
		}

		if ( start < duration - TimeTolerance )
			result.Add( Span( part, start, duration, state ) );
		else if ( result.Count == 0 )
			result.Add( Span( part, 0, duration, state ) );

		return result;
	}

	private static WeaponVisibilitySpan Span(
		WeaponVisibilityPart part,
		float start,
		float end,
		bool visible ) => new(
			visible ? VisibleTag( part.Id ) : HiddenTag( part.Id ),
			start,
			end,
			visible );
}
sonac.sbox-animator / Editor/Services/AnimGraphWriter.cs
Editor library
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;

namespace SboxWeaponAnimator.Editor;

public static class AnimGraphWriter
{
	public const string Header =
		"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} " +
		"format:animgraph2:version{0f7898b8-5471-45c4-9867-cd9c46bcfdb5} -->";

	private sealed record StateSpec(
		string Name,
		WeaponClipRole Role,
		bool Loop,
		List<string> Transitions,
		bool Start = false );

	public static string Write( WeaponAnimationDocument document, string hostModelPath )
	{
		var idle = document.Clips.First( x => x.Role == WeaponClipRole.Idle );
		WeaponAnimationClip ClipFor( WeaponClipRole role )
		{
			var clip = document.Clips.FirstOrDefault( x => x.Role == role );
			return clip is not null && clip.Readiness != ClipReadiness.NotStarted
				? clip
				: idle;
		}

		var states = BuildStates( document );
		var nodes = new StringBuilder();
		var x = -960.0f;
		foreach ( var state in states )
		{
			var sequenceClip = ClipFor( state.Role );
			nodes.AppendLine( SequenceNode(
				$"seq_{state.Name}",
				WeaponAnimationNames.SequenceName( sequenceClip ),
				sequenceClip,
				document.Rig.VisibilityParts,
				state.Loop,
				x,
				96 ) );
			x += 144;
		}

		nodes.AppendLine( StateMachineNode( states ) );
		nodes.AppendLine( RootNode() );

		var parameters = string.Join( "\n", ParameterDefinitions() );
		var tags = string.Join( "\n", StandardTags( document ) );
		return $$"""
			{{Header}}
			// SboxWeaponAnimator generated graph. Node, state, parameter, and tag IDs are deterministic.
			{
				_class = "CAnimationGraph"
				m_nodeManager =
				{
					_class = "CAnimNodeManager"
					m_nodes =
					[
			{{nodes}}		]
				}
				m_pParameterList =
				{
					_class = "CAnimParameterList"
					m_Parameters =
					[
			{{parameters}}
					]
				}
				m_pTagManager =
				{
					_class = "CAnimTagManager"
					m_tags =
					[
			{{tags}}
					]
				}
				m_pMovementManager =
				{
					_class = "CAnimMovementManager"
					m_MotorList = { _class = "CAnimMotorList" m_motors = [ ] }
					m_MovementSettings =
					{
						_class = "CAnimMovementSettings"
						m_bShouldCalculateSlope = false
					}
				}
				m_pSettingsManager =
				{
					_class = "CAnimGraphSettingsManager"
					m_settingsGroups =
					[
						{ _class = "CAnimGraphGeneralSettings" m_iGridSnap = 16 },
					]
				}
				m_pActivityValuesList = { _class = "CActivityValueList" m_activities = [ ] }
				m_previewModels = [ "{{hostModelPath}}", ]
				m_boneMergeModels =
				[
					{
						m_name = "{{HostSkeletonBuilder.ProductionArmsModel}}"
						m_bEnabled = true
					},
				]
				m_cameraSettings =
				{
					m_flFov = {{F( document.Calibration.HorizontalFov )}}
					m_sLockBoneName = "camera"
					m_bLockCamera = true
					m_bViewModelCamera = false
				}
			}
			""";
	}

	private static List<StateSpec> BuildStates( WeaponAnimationDocument document )
	{
		var idleTransitions = new List<string>
		{
			Transition( [BoolCondition( "b_attack_dry", true )], "FireDry", 0.02f ),
			Transition( [BoolCondition( "b_attack", true )], "Fire", 0.02f ),
			Transition(
				[BoolCondition( "b_reload", true ), BoolCondition( "b_empty", true )],
				"ReloadEmpty",
				0.05f ),
			Transition( [BoolCondition( "b_reload", true )], "Reload", 0.05f ),
			Transition( [BoolCondition( "b_deploy", true )], "Deploy", 0.05f ),
			Transition( [BoolCondition( "b_holster", true )], "Holster", 0.05f ),
			Transition( [BoolCondition( "b_inspect", true )], "Inspect", 0.08f ),
			Transition( [BoolCondition( "b_sprint", true )], "Sprint", 0.08f ),
			Transition( [BoolCondition( "b_jump", true )], "Jump", 0.05f ),
			Transition( [BoolCondition( "b_lower_weapon", true )], "Lower", 0.08f ),
			Transition( [IntCondition( "ironsights", 1 )], "Ironsights", 0.08f ),
			Transition( [BoolCondition( "b_grab", true )], "GrabStance", 0.08f ),
			Transition( [IntCondition( "grab_action", 1 )], "GrabGesture1", 0.04f ),
			Transition( [IntCondition( "grab_action", 2 )], "GrabGesture2", 0.04f ),
			Transition( [IntCondition( "grab_action", 3 )], "GrabGesture3", 0.04f ),
			Transition( [IntCondition( "grab_action", 4 )], "GrabGesture4", 0.04f )
		};

		if ( document.Graph.ReloadProfile == ReloadProfile.Incremental )
			idleTransitions.Insert( 3, Transition( [BoolCondition( "b_reloading", true )], "ReloadEnter", 0.05f ) );

		var finishedToIdle = new List<string> { Transition( [FinishedCondition()], "Idle", 0.08f ) };
		var states = new List<StateSpec>
		{
			new( "Idle", WeaponClipRole.Idle, true, idleTransitions, true ),
			new( "Deploy", WeaponClipRole.Deploy, false, finishedToIdle ),
			new( "Fire", WeaponClipRole.Fire, false, [
				Transition( [BoolCondition( "b_attack", true )], "Fire", 0.01f ),
				Transition( [FinishedCondition()], "Idle", 0.06f )
			] ),
			new( "FireDry", WeaponClipRole.FireDry, false, finishedToIdle ),
			new( "Reload", WeaponClipRole.Reload, false, finishedToIdle ),
			new( "ReloadEmpty", WeaponClipRole.ReloadEmpty, false, finishedToIdle ),
			new( "Holster", WeaponClipRole.Holster, false, [] ),
			new( "Inspect", WeaponClipRole.Inspect, false, finishedToIdle ),
			new( "Sprint", WeaponClipRole.Sprint, true, [
				Transition( [BoolCondition( "b_sprint", false )], "Idle", 0.08f, false )
			] ),
			new( "Jump", WeaponClipRole.Jump, false, finishedToIdle ),
			new( "Lower", WeaponClipRole.Lower, true, [
				Transition( [BoolCondition( "b_lower_weapon", false )], "Idle", 0.08f, false )
			] ),
			new( "Ironsights", WeaponClipRole.Ironsights, true, [
				Transition( [IntCondition( "ironsights", 0 )], "Idle", 0.08f, false )
			] ),
			new( "GrabStance", WeaponClipRole.GrabStance, true, [
				Transition( [BoolCondition( "b_grab", false )], "Idle", 0.08f, false )
			] ),
			new( "GrabGesture1", WeaponClipRole.GrabGestureOne, false, finishedToIdle ),
			new( "GrabGesture2", WeaponClipRole.GrabGestureTwo, false, finishedToIdle ),
			new( "GrabGesture3", WeaponClipRole.GrabGestureThree, false, finishedToIdle ),
			new( "GrabGesture4", WeaponClipRole.GrabGestureFour, false, finishedToIdle )
		};

		if ( document.Graph.ReloadProfile == ReloadProfile.Incremental )
		{
			states.AddRange(
			[
				new( "ReloadEnter", WeaponClipRole.ReloadEnter, false, [
					Transition( [FinishedCondition()], "FirstShell", 0.04f )
				] ),
				new( "FirstShell", WeaponClipRole.FirstShell, false, [
					Transition( [BoolCondition( "b_reloading", false )], "ReloadExit", 0.04f ),
					Transition( [FinishedCondition()], "InsertShell", 0.04f )
				] ),
				new( "InsertShell", WeaponClipRole.InsertShell, false, [
					Transition( [BoolCondition( "b_reloading", false )], "ReloadExit", 0.04f ),
					Transition( [FinishedCondition()], "InsertShell", 0.02f )
				] ),
				new( "ReloadExit", WeaponClipRole.ReloadExit, false, finishedToIdle )
			] );
		}

		return states;
	}

	private static string SequenceNode(
		string name,
		string sequence,
		WeaponAnimationClip clip,
		IReadOnlyList<WeaponVisibilityPart> visibilityParts,
		bool loop,
		float x,
		float y )
	{
		var id = Id( $"node:{name}" );
		var visibilityTags = visibilityParts.SelectMany( part =>
			WeaponVisibilityEvaluator.BuildSpans( part, clip ).Select( span =>
				new AnimationTag
				{
					Name = span.Name,
					Kind = AnimationTagKind.Range,
					StartTime = span.StartTime,
					EndTime = span.EndTime
				} ) );
		var tagSpans = string.Join( "\n", clip.Tags
			.Concat( visibilityTags )
			.Where( x => !string.IsNullOrWhiteSpace( x.Name ) )
			.OrderBy( x => x.StartTime )
			.ThenBy( x => x.Name )
			.Select( x => TagSpan( x, clip ) ) );
		return $$"""
						{
							key = { m_id = {{id}} }
							value =
							{
								_class = "CSequenceAnimNode"
								m_sName = "{{name}}"
								m_vecPosition = [ {{F( x )}}, {{F( y )}} ]
								m_nNodeID = { m_id = {{id}} }
								m_sNote = ""
								m_tagSpans =
								[
			{{tagSpans}}
								]
								m_sequenceName = "{{sequence}}"
								m_playbackSpeed = 1.0
								m_bLoop = {{loop.ToString().ToLowerInvariant()}}
							}
						},
			""";
	}

	private static string TagSpan( AnimationTag tag, WeaponAnimationClip clip )
	{
		var duration = MathF.Max( clip.Duration, 0.0001f );
		var start = Math.Clamp( tag.StartTime / duration, 0, 1 );
		var tagDuration = tag.Kind == AnimationTagKind.Point
			? MathF.Min( 1.0f / MathF.Max( clip.SampleRate, 1 ) / duration, 1.0f - start )
			: Math.Clamp( (tag.EndTime - tag.StartTime) / duration, 0, 1.0f - start );
		return $$"""
									{
										_class = "CAnimTagSpan"
										m_id = { m_id = {{Id( $"tag:{tag.Name}" )}} }
										m_fStartCycle = {{F( start )}}
										m_fDuration = {{F( tagDuration )}}
									},
			""";
	}

	private static string StateMachineNode( IEnumerable<StateSpec> states )
	{
		var stateText = string.Join( "\n", states.Select( StateNode ) );
		var id = Id( "node:StateMachine" );
		return $$"""
						{
							key = { m_id = {{id}} }
							value =
							{
								_class = "CStateMachineAnimNode"
								m_sName = "Weapon States"
								m_vecPosition = [ -224.0, 304.0 ]
								m_nNodeID = { m_id = {{id}} }
								m_sNote = ""
								m_states =
								[
			{{stateText}}
								]
							}
						},
			""";
	}

	private static string StateNode( StateSpec state )
	{
		var transitions = string.Join( "\n", state.Transitions );
		return $$"""
									{
										_class = "CAnimState"
										m_transitions =
										[
			{{transitions}}
										]
										m_tags = [ ]
										m_tagBehaviors = [ ]
										m_name = "{{state.Name}}"
										m_inputConnection =
										{
											m_nodeID = { m_id = {{Id( $"node:seq_{state.Name}" )}} }
											m_outputID = { m_id = 4294967295 }
										}
										m_stateID = { m_id = {{Id( $"state:{state.Name}" )}} }
										m_position = [ 0.0, 0.0 ]
										m_bIsStartState = {{state.Start.ToString().ToLowerInvariant()}}
										m_bIsEndtState = false
										m_bIsPassthrough = false
										m_bIsRootMotionExclusive = false
										m_bAlwaysEvaluate = false
									},
			""";
	}

	private static string RootNode()
	{
		var id = Id( "node:Root" );
		return $$"""
						{
							key = { m_id = {{id}} }
							value =
							{
								_class = "CRootAnimNode"
								m_sName = "Output"
								m_vecPosition = [ 48.0, 256.0 ]
								m_nNodeID = { m_id = {{id}} }
								m_sNote = ""
								m_inputConnection =
								{
									m_nodeID = { m_id = {{Id( "node:StateMachine" )}} }
									m_outputID = { m_id = 4294967295 }
								}
							}
						},
			""";
	}

	private static string Transition(
		IEnumerable<string> conditions,
		string destination,
		float blend,
		bool reset = true )
	{
		var conditionText = string.Join( "\n", conditions );
		return $$"""
											{
												_class = "CAnimStateTransition"
												m_conditions =
												[
			{{conditionText}}
												]
												m_blendDuration = {{F( blend )}}
												m_destState = { m_id = {{Id( $"state:{destination}" )}} }
												m_bReset = {{reset.ToString().ToLowerInvariant()}}
												m_resetCycleOption = "Beginning"
												m_flFixedCycleValue = 0.0
												m_blendCurve =
												{
													m_vControlPoint1 = [ 0.5, 0.0 ]
													m_vControlPoint2 = [ 0.5, 1.0 ]
												}
												m_bForceFootPlant = false
												m_bDisabled = false
												m_bRandomTimeBetween = false
												m_flRandomTimeStart = 0.0
												m_flRandomTimeEnd = 0.0
											},
			""";
	}

	private static string BoolCondition( string name, bool value ) => $$"""
													{
														_class = "CParameterAnimCondition"
														m_comparisonOp = 0
														m_paramID = { m_id = {{Id( $"param:{name}" )}} }
														m_comparisonValue = { m_nType = 1 m_data = {{value.ToString().ToLowerInvariant()}} }
													},
			""";

	private static string IntCondition( string name, int value ) => $$"""
													{
														_class = "CParameterAnimCondition"
														m_comparisonOp = 0
														m_paramID = { m_id = {{Id( $"param:{name}" )}} }
														m_comparisonValue = { m_nType = 3 m_data = {{value}} }
													},
			""";

	private static string FinishedCondition() => """
													{
														_class = "CFinishedCondition"
														m_comparisonOp = 0
														m_option = "FinishedConditionOption_OnAlmostFinished"
														m_bIsFinished = true
													},
			""";

	private static IEnumerable<string> ParameterDefinitions()
	{
		var pulseBools = new HashSet<string>( StringComparer.OrdinalIgnoreCase )
		{
			"b_attack", "b_attack_dry", "b_jump", "b_reload", "b_deploy", "b_inspect",
			"b_reloading_shell", "b_reloading_first_shell"
		};
		var bools = new[]
		{
			"b_grounded", "b_jump", "b_sprint", "b_attack", "b_attack_dry", "b_attack_has_hit",
			"b_reload", "b_empty", "b_deploy", "b_deploy_skip", "b_deploy_first",
			"b_twohanded", "b_lower_weapon", "b_holster", "b_grab", "b_inspect",
			"b_reloading", "b_reloading_shell", "b_reloading_first_shell"
		};
		foreach ( var name in bools )
			yield return BoolParameter( name, pulseBools.Contains( name ) );

		var floats = new (string Name, float Default, float Minimum, float Maximum)[]
		{
			("move_bob", 0, 0, 1),
			("move_bob_cycle_control", 0, 0, 1),
			("move_x", 0, -1, 1),
			("move_y", 0, -1, 1),
			("move_z", 0, -1, 1),
			("attack_hold", 0, 0, 1),
			("ironsights_fire_scale", 0, 0, 1),
			("camera_position_scale", 1, 0, 2),
			("camera_rotation_scale", 1, 0, 2),
			("speed_reload", 1, 0.05f, 5),
			("speed_deploy", 1, 0.05f, 5),
			("speed_ironsights", 1, 0.05f, 5),
			("speed_grab", 1, 0.05f, 5),
			("aim_pitch_inertia", 0, -45, 45),
			("aim_yaw_inertia", 0, -45, 45)
		};
		foreach ( var item in floats )
			yield return FloatParameter( item.Name, item.Default, item.Minimum, item.Maximum );

		yield return EnumParameter( "ironsights", ["Hip", "ADS"] );
		yield return EnumParameter( "firing_mode", ["Safe", "Single", "Burst", "Automatic"] );
		yield return EnumParameter( "weapon_pose", ["Default", "Alternate"] );
		yield return EnumParameter( "grab_action", ["None", "Sweep Down", "Sweep Right", "Sweep Left", "Push"] );
		yield return EnumParameter( "deploy_type", ["Default", "Alternate"] );
		yield return EnumParameter( "reload_type", ["Default", "Alternate"] );
		yield return EnumParameter( "skeleton", ["Human", "Citizen"] );
	}

	private static string BoolParameter( string name, bool autoReset ) => $$"""
						{
							_class = "CBoolAnimParameter"
							m_name = "{{name}}"
							m_id = { m_id = {{Id( $"param:{name}" )}} }
							m_previewButton = "ANIMPARAM_BUTTON_NONE"
							m_bUseMostRecentValue = false
							m_bAutoReset = {{autoReset.ToString().ToLowerInvariant()}}
							m_bDefaultValue = false
						},
			""";

	private static string FloatParameter( string name, float value, float min, float max ) => $$"""
						{
							_class = "CFloatAnimParameter"
							m_name = "{{name}}"
							m_id = { m_id = {{Id( $"param:{name}" )}} }
							m_previewButton = "ANIMPARAM_BUTTON_NONE"
							m_bUseMostRecentValue = false
							m_bAutoReset = false
							m_fDefaultValue = {{F( value )}}
							m_fMinValue = {{F( min )}}
							m_fMaxValue = {{F( max )}}
						},
			""";

	private static string EnumParameter( string name, IEnumerable<string> choices )
	{
		var options = string.Join( "\n", choices.Select( x => $"\t\t\t\t\t\t\"{x}\"," ) );
		return $$"""
						{
							_class = "CEnumAnimParameter"
							m_name = "{{name}}"
							m_id = { m_id = {{Id( $"param:{name}" )}} }
							m_previewButton = "ANIMPARAM_BUTTON_NONE"
							m_bUseMostRecentValue = false
							m_bAutoReset = {{(name == "grab_action").ToString().ToLowerInvariant()}}
							m_defaultValue = 0
							m_enumOptions =
							[
			{{options}}
							]
						},
			""";
	}

	private static IEnumerable<string> StandardTags( WeaponAnimationDocument document )
	{
		var tags = new HashSet<string>( StringComparer.OrdinalIgnoreCase )
		{
			"attack_discouraged",
			"holster_finished",
			"reload_bodygroup",
			"reload_increment"
		};

		foreach ( var tag in document.Clips.SelectMany( x => x.Tags ) )
			tags.Add( tag.Name );
		foreach ( var part in document.Rig.VisibilityParts )
		{
			tags.Add( WeaponVisibilityEvaluator.VisibleTag( part.Id ) );
			tags.Add( WeaponVisibilityEvaluator.HiddenTag( part.Id ) );
		}

		foreach ( var name in tags.OrderBy( x => x ) )
		{
			yield return $$"""
						{
							_class = "CStringAnimTag"
							m_name = "{{name}}"
							m_tagID = { m_id = {{Id( $"tag:{name}" )}} }
						},
			""";
		}
	}

	public static uint Id( string value )
	{
		uint crc = 0xFFFFFFFF;
		foreach ( var data in Encoding.UTF8.GetBytes( value ) )
		{
			crc ^= data;
			for ( var bit = 0; bit < 8; bit++ )
				crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;
		}

		var result = (crc ^ 0xFFFFFFFF) & 0x7FFFFFFF;
		return result == 0 ? 1u : result;
	}

	private static string F( float value ) =>
		value.ToString( "0.######", CultureInfo.InvariantCulture );
}
sonac.sbox-animator / Editor/Services/ModelDocWriter.cs
Editor library
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed record HostWeaponMesh(
	string SourcePath,
	string SourceRootBoneName,
	Transform ImportTransform,
	IReadOnlyList<string> ExcludedBranchRoots,
	IReadOnlyList<HostMaterialRemap>? MaterialRemaps = null );

public sealed record HostMaterialRemap(
	string SourceMaterial,
	string TargetMaterial );

public sealed record HostAttachment(
	string Name,
	string ParentBone,
	Vector3 LocalPosition,
	Rotation LocalRotation );

public static class ModelDocWriter
{
	public const string Header =
		"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} " +
		"format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->";

	public static string WriteHost(
		string referenceMesh,
		IEnumerable<(WeaponAnimationClip Clip, string Source)> clips,
		string animGraphPath,
		IEnumerable<string> preservedBones,
		HostWeaponMesh? weaponMesh = null,
		IEnumerable<HostAttachment>? attachments = null,
		string baseModelPath = "",
		IEnumerable<HostMaterialRemap>? baseMaterialRemaps = null )
	{
		var animationNodes = new StringBuilder();
		foreach ( var item in clips.OrderBy( x => x.Clip.Name ) )
		{
			animationNodes.AppendLine( $$"""
								{
									_class = "AnimFile"
									name = "{{WeaponAnimationNames.SequenceName( item.Clip )}}"
									activity_name = ""
									activity_weight = 1
									weight_list_name = ""
									fade_in_time = 0.1
									fade_out_time = 0.1
									looping = {{item.Clip.Loop.ToString().ToLowerInvariant()}}
									delta = false
									worldSpace = false
									hidden = false
									anim_markup_ordered = false
									disable_compression = false
									disable_interpolation = false
									enable_scale = true
									source_filename = "{{item.Source}}"
									start_frame = -1
									end_frame = -1
									framerate = {{F( item.Clip.SampleRate )}}
									take = 0
									reverse = false
								},
			""" );
		}

		var weaponMeshNode = BuildWeaponMeshNode( weaponMesh );
		var materialGroup = BuildMaterialGroup(
			weaponMesh is not null || !string.IsNullOrWhiteSpace( baseModelPath ),
			weaponMesh?.MaterialRemaps ?? baseMaterialRemaps );
		var attachmentList = BuildAttachmentList( attachments );
		var boneMarkupNodes = new StringBuilder();
		foreach ( var boneName in preservedBones
			.Where( name => !string.IsNullOrWhiteSpace( name ) )
			.Distinct( System.StringComparer.OrdinalIgnoreCase )
			.OrderBy( name => name, System.StringComparer.OrdinalIgnoreCase ) )
		{
			boneMarkupNodes.AppendLine( $$"""
										{
											_class = "BoneMarkup"
											target_bone = "{{Escape( boneName )}}"
											ignore_Translation = false
											ignore_rotation = false
											do_not_discard = true
										},
				""" );
		}

		return $$"""
			{{Header}}
			// SboxWeaponAnimator generated file. Ownership is recorded in weaponanim.manifest.json.
			{
				rootNode =
				{
					_class = "RootNode"
					children =
					[
						{
							_class = "MaterialGroupList"
							children =
							[
			{{materialGroup}}
							]
						},
						{
							_class = "RenderMeshList"
							children =
							[
								{
									_class = "RenderMeshFile"
									name = "animation_host"
									filename = "{{referenceMesh}}"
									import_translation = [ 0.0, 0.0, 0.0 ]
									import_rotation = [ 0.0, 0.0, 0.0 ]
									import_scale = 1.0
									align_origin_x_type = "None"
									align_origin_y_type = "None"
									align_origin_z_type = "None"
									parent_bone = ""
									import_filter = { exclude_by_default = false exception_list = [ ] }
								},
			{{weaponMeshNode}}
							]
						},
						{
							_class = "AnimationList"
							children =
							[
			{{animationNodes}}				]
							default_root_bone_name = ""
						},
			{{attachmentList}}
						{
							_class = "BoneMarkupList"
							children =
							[
			{{boneMarkupNodes}}				]
							bone_cull_type = "None"
						},
					]
					model_archetype = ""
					primary_associated_entity = ""
					anim_graph_name = "{{animGraphPath}}"
					base_model_name = "{{Escape( baseModelPath )}}"
				}
			}
			""";
	}

	private static string BuildMaterialGroup(
		bool includesImportedWeapon,
		IEnumerable<HostMaterialRemap>? materialRemaps )
	{
		if ( !includesImportedWeapon )
		{
			return """
								{
									_class = "DefaultMaterialGroup"
									remaps = [ ]
									use_global_default = false
									global_default_material = "materials/default.vmat"
								},
				""";
		}

		var remaps = new List<HostMaterialRemap>
		{
			new(
				"materials/tools/toolsinvisible.vmat",
				"materials/tools/toolsinvisible.vmat" )
		};
		remaps.AddRange( materialRemaps?
			.Where( remap => !string.IsNullOrWhiteSpace( remap.SourceMaterial )
				&& !string.IsNullOrWhiteSpace( remap.TargetMaterial ) )
			?? [] );

		var remapText = new StringBuilder();
		foreach ( var remap in remaps
			.DistinctBy(
				remap => remap.SourceMaterial,
				System.StringComparer.OrdinalIgnoreCase )
			.OrderBy(
				remap => remap.SourceMaterial,
				System.StringComparer.OrdinalIgnoreCase ) )
		{
			remapText.AppendLine( $$"""
										{
											from = "{{Escape( remap.SourceMaterial )}}"
											to = "{{Escape( remap.TargetMaterial )}}"
										},
				""" );
		}

		// Every imported slot is mapped independently. Global substitution would collapse
		// multi-material weapons to a single texture.
		return $$"""
							{
								_class = "DefaultMaterialGroup"
								remaps =
								[
			{{remapText}}					]
								use_global_default = false
								global_default_material = "materials/default.vmat"
							},
			""";
	}

	private static string BuildAttachmentList( IEnumerable<HostAttachment>? attachments )
	{
		var items = attachments?
			.Where( x => !string.IsNullOrWhiteSpace( x.Name )
				&& !string.IsNullOrWhiteSpace( x.ParentBone ) )
			.OrderBy( x => x.Name, System.StringComparer.OrdinalIgnoreCase )
			.ToArray() ?? [];
		if ( items.Length == 0 )
			return "";

		var nodes = new StringBuilder();
		foreach ( var attachment in items )
		{
			var angles = attachment.LocalRotation.Angles();
			nodes.AppendLine( $$"""
								{
									_class = "Attachment"
									name = "{{Escape( attachment.Name )}}"
									parent_bone = "{{Escape( attachment.ParentBone )}}"
									relative_origin = [ {{F( attachment.LocalPosition.x )}}, {{F( attachment.LocalPosition.y )}}, {{F( attachment.LocalPosition.z )}} ]
									relative_angles = [ {{F( angles.pitch )}}, {{F( angles.yaw )}}, {{F( angles.roll )}} ]
									weight = 1.0
									ignore_rotation = false
								},
				""" );
		}

		return $$"""
						{
							_class = "AttachmentList"
							children =
							[
			{{nodes}}				]
						},

			""";
	}

	private static string BuildWeaponMeshNode( HostWeaponMesh? source )
	{
		if ( source is null || string.IsNullOrWhiteSpace( source.SourcePath ) )
			return "";

		var modifiers = new StringBuilder();
		if ( !string.IsNullOrWhiteSpace( source.SourceRootBoneName )
			&& !source.SourceRootBoneName.Equals(
				"weapon_root",
				System.StringComparison.OrdinalIgnoreCase ) )
		{
			modifiers.AppendLine( $$"""
											{
												_class = "RenameBonePrefix"
												prefix_to_match = "{{Escape( source.SourceRootBoneName )}}"
												replacement = "weapon_root"
												allow_nonmatching_bones = true
											},
				""" );
		}

		var excluded = source.ExcludedBranchRoots
			.Where( x => !string.IsNullOrWhiteSpace( x ) )
			.Distinct( System.StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, System.StringComparer.OrdinalIgnoreCase )
			.ToArray();
		if ( excluded.Length > 0 )
		{
			var names = string.Join(
				"\n",
				excluded.Select( x => $"\t\t\t\t\t\t\t\t\t\t\t\"{Escape( x )}\"," ) );
			modifiers.AppendLine( $$"""
											{
												_class = "RemoveBoneAndChildren"
												bone_names =
												[
				{{names}}
												]
											},
				""" );
		}

		var children = modifiers.Length == 0
			? ""
			: $$"""
										children =
										[
				{{modifiers}}							]

				""";
		var angles = source.ImportTransform.Rotation.Angles();
		return $$"""
								{
									_class = "RenderMeshFile"
									name = "weapon"
									filename = "{{Escape( source.SourcePath )}}"
									import_translation = [ {{F( source.ImportTransform.Position.x )}}, {{F( source.ImportTransform.Position.y )}}, {{F( source.ImportTransform.Position.z )}} ]
									import_rotation = [ {{F( angles.pitch )}}, {{F( angles.yaw )}}, {{F( angles.roll )}} ]
									import_scale = {{F( source.ImportTransform.Scale.x )}}
									align_origin_x_type = "None"
									align_origin_y_type = "None"
									align_origin_z_type = "None"
									parent_bone = ""
									import_filter = { exclude_by_default = false exception_list = [ ] }
			{{children}}					},
			""";
	}

	public static string WriteSourceWrapper(
		string sourcePath,
		string sourceRootBoneName = "",
		System.Collections.Generic.IEnumerable<string>? excludedBranchRoots = null,
		System.Collections.Generic.IEnumerable<HostMaterialRemap>? materialRemaps = null )
	{
		var modifierList = BuildSourceModifierList(
			sourceRootBoneName,
			excludedBranchRoots );
		var materialGroup = BuildMaterialGroup( true, materialRemaps );

		return $$"""
			{{Header}}
			// SboxWeaponAnimator generated source wrapper.
			{
			rootNode =
			{
				_class = "RootNode"
				children =
				[
					{
						_class = "MaterialGroupList"
						children =
						[
			{{materialGroup}}
						]
					},
					{
						_class = "RenderMeshList"
						children =
						[
							{
								_class = "RenderMeshFile"
								name = "source_weapon"
								filename = "{{sourcePath}}"
								import_translation = [ 0.0, 0.0, 0.0 ]
								import_rotation = [ 0.0, 0.0, 0.0 ]
								import_scale = 1.0
								align_origin_x_type = "None"
								align_origin_y_type = "None"
								align_origin_z_type = "None"
								parent_bone = ""
								import_filter = { exclude_by_default = false exception_list = [ ] }
							},
						]
						},
						{ _class = "BoneMarkupList" bone_cull_type = "None" },
			{{modifierList}}			
					]
					model_archetype = ""
				primary_associated_entity = ""
				anim_graph_name = ""
				base_model_name = ""
				}
			}
			""";
	}

	public static string WriteVmdlSourceAdapter(
		string sourceModelDoc,
		string sourceRootBoneName,
		System.Collections.Generic.IEnumerable<string>? excludedBranchRoots = null,
		Transform? importTransform = null )
	{
		if ( importTransform is { } placement )
			sourceModelDoc = ApplyRenderMeshImportTransform( sourceModelDoc, placement );

		var modifierList = BuildSourceModifierList(
			sourceRootBoneName,
			excludedBranchRoots );
		if ( string.IsNullOrWhiteSpace( modifierList ) )
			return sourceModelDoc;

		var rootIndex = sourceModelDoc.IndexOf( "rootNode", System.StringComparison.Ordinal );
		var childrenIndex = rootIndex < 0
			? -1
			: sourceModelDoc.IndexOf( "children", rootIndex, System.StringComparison.Ordinal );
		var openingBracket = childrenIndex < 0
			? -1
			: sourceModelDoc.IndexOf( '[', childrenIndex );
		if ( openingBracket < 0 )
			throw new System.InvalidOperationException( "The source VMDL does not expose a writable root child list." );

		var insertion = "\n" + modifierList.Trim() + "\n";
		return sourceModelDoc.Insert( openingBracket + 1, insertion );
	}

	internal static string ApplyRenderMeshImportTransform(
		string sourceModelDoc,
		Transform placement )
	{
		var blocks = new List<(int Start, int End)>();
		var search = 0;
		while ( true )
		{
			var classIndex = sourceModelDoc.IndexOf(
				"_class = \"RenderMeshFile\"",
				search,
				StringComparison.Ordinal );
			if ( classIndex < 0 )
				break;
			var opening = sourceModelDoc.LastIndexOf( '{', classIndex );
			var closing = opening < 0 ? -1 : FindClosingBrace( sourceModelDoc, opening );
			if ( opening < 0 || closing < 0 )
				throw new InvalidOperationException(
					"The source VMDL contains a malformed RenderMeshFile node." );
			blocks.Add( (opening, closing + 1) );
			search = closing + 1;
		}

		if ( blocks.Count == 0 )
			throw new InvalidOperationException(
				"The source VMDL does not contain an editable RenderMeshFile node." );

		var result = new StringBuilder( sourceModelDoc );
		foreach ( var (start, end) in blocks.OrderByDescending( block => block.Start ) )
		{
			var block = sourceModelDoc[start..end];
			var sourceAngles = ReadVector( block, "import_rotation", Vector3.Zero );
			var source = new Transform(
				ReadVector( block, "import_translation", Vector3.Zero ),
				Rotation.From( sourceAngles.x, sourceAngles.y, sourceAngles.z ),
				new Vector3( ReadScalar( block, "import_scale", 1 ) ) );
			var combined = new Transform(
				placement.PointToWorld( source.Position ),
				placement.Rotation * source.Rotation,
				placement.Scale * source.Scale );
			var angles = combined.Rotation.Angles();
			block = ReplaceField(
				block,
				"import_translation",
				$"[ {F( combined.Position.x )}, {F( combined.Position.y )}, {F( combined.Position.z )} ]" );
			block = ReplaceField(
				block,
				"import_rotation",
				$"[ {F( angles.pitch )}, {F( angles.yaw )}, {F( angles.roll )} ]" );
			block = ReplaceField( block, "import_scale", F( combined.Scale.x ) );
			result.Remove( start, end - start );
			result.Insert( start, block );
		}
		return result.ToString();
	}

	private static string ReplaceField( string block, string name, string value )
	{
		var pattern = $@"(?m)^(\s*){Regex.Escape( name )}\s*=\s*(\[[^\]]*\]|[^\r\n]+)";
		if ( Regex.IsMatch( block, pattern ) )
			return new Regex( pattern ).Replace(
				block,
				$"${{1}}{name} = {value}",
				1 );

		var classLine = block.IndexOf(
			"_class = \"RenderMeshFile\"",
			StringComparison.Ordinal );
		var lineEnd = classLine < 0 ? -1 : block.IndexOf( '\n', classLine );
		if ( lineEnd < 0 )
			throw new InvalidOperationException(
				$"The source RenderMeshFile cannot receive '{name}'." );
		var indentation = Regex.Match( block[(block.LastIndexOf( '\n', classLine ) + 1)..], @"^\s*" ).Value;
		return block.Insert( lineEnd + 1, $"{indentation}{name} = {value}\n" );
	}

	private static Vector3 ReadVector( string block, string name, Vector3 fallback )
	{
		var match = Regex.Match(
			block,
			$@"(?m)^\s*{Regex.Escape( name )}\s*=\s*\[\s*({NumberPattern})\s*,\s*({NumberPattern})\s*,\s*({NumberPattern})\s*\]" );
		return match.Success
			? new Vector3(
				ParseNumber( match.Groups[1].Value ),
				ParseNumber( match.Groups[2].Value ),
				ParseNumber( match.Groups[3].Value ) )
			: fallback;
	}

	private static float ReadScalar( string block, string name, float fallback )
	{
		var match = Regex.Match(
			block,
			$@"(?m)^\s*{Regex.Escape( name )}\s*=\s*({NumberPattern})" );
		return match.Success ? ParseNumber( match.Groups[1].Value ) : fallback;
	}

	private static float ParseNumber( string value ) =>
		float.Parse( value, NumberStyles.Float, CultureInfo.InvariantCulture );

	private static int FindClosingBrace( string text, int opening )
	{
		var depth = 0;
		var quoted = false;
		var escaped = false;
		for ( var index = opening; index < text.Length; index++ )
		{
			var character = text[index];
			if ( quoted )
			{
				if ( escaped )
					escaped = false;
				else if ( character == '\\' )
					escaped = true;
				else if ( character == '"' )
					quoted = false;
				continue;
			}
			if ( character == '"' )
			{
				quoted = true;
				continue;
			}
			if ( character == '/' && index + 1 < text.Length )
			{
				if ( text[index + 1] == '/' )
				{
					index = text.IndexOf( '\n', index + 2 );
					if ( index < 0 )
						return -1;
					continue;
				}
				if ( text[index + 1] == '*' )
				{
					index = text.IndexOf( "*/", index + 2, StringComparison.Ordinal );
					if ( index < 0 )
						return -1;
					index++;
					continue;
				}
			}
			if ( character == '{' )
				depth++;
			else if ( character == '}' && --depth == 0 )
				return index;
		}
		return -1;
	}

	private const string NumberPattern = @"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?";

	private static string BuildSourceModifierList(
		string sourceRootBoneName,
		System.Collections.Generic.IEnumerable<string>? excludedBranchRoots )
	{
		var modifiers = new System.Collections.Generic.List<string>();
		if ( !string.IsNullOrWhiteSpace( sourceRootBoneName )
			&& !sourceRootBoneName.Equals( "weapon_root", System.StringComparison.OrdinalIgnoreCase ) )
		{
			modifiers.Add( $$"""
									{
										_class = "RenameBone"
										original_bone_name = "{{Escape( sourceRootBoneName )}}"
										new_bone_name = "weapon_root"
									},
				""" );
		}

		var excluded = excludedBranchRoots?
			.Where( x => !string.IsNullOrWhiteSpace( x ) )
			.Distinct( System.StringComparer.OrdinalIgnoreCase )
			.OrderBy( x => x, System.StringComparer.OrdinalIgnoreCase )
			.ToArray() ?? [];
		if ( excluded.Length > 0 )
		{
			var boneNames = string.Join(
				"\n",
				excluded.Select( x => $"\t\t\t\t\t\t\t\t\t\"{Escape( x )}\"," ) );
			modifiers.Add( $$"""
									{
										_class = "RemoveBoneAndChildren"
										bone_names =
										[
										{{boneNames}}
										]
									},
				""" );
		}

		var modifierList = modifiers.Count == 0
				? ""
				: $$"""
							{
								_class = "ModelModifierList"
								children =
								[
								{{string.Join( "\n", modifiers )}}
								]
							},

						""";
		return modifierList;
	}

	private static string F( float value ) =>
		value.ToString( "0.######", CultureInfo.InvariantCulture );

	private static string Escape( string value ) =>
		value.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" );
}
sonac.sbox-animator / Editor/Widgets/WeaponAnimatorTheme.cs
Editor library
#nullable enable annotations

using System;
using System.Linq;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public static class WeaponAnimatorTheme
{
	public static readonly Color Background = new( 0.052f, 0.058f, 0.064f );
	public static readonly Color Surface = new( 0.082f, 0.091f, 0.101f );
	public static readonly Color SurfaceRaised = new( 0.105f, 0.115f, 0.126f );
	public static readonly Color Border = Color.White.WithAlpha( 0.075f );
	public static readonly Color Text = new( 0.88f, 0.90f, 0.92f );
	public static readonly Color Muted = new( 0.52f, 0.56f, 0.61f );
	public static readonly Color Cyan = new( 0.15f, 0.78f, 0.91f );
	public static readonly Color Amber = new( 0.96f, 0.61f, 0.16f );
	public static readonly Color Green = new( 0.34f, 0.82f, 0.50f );
	public static readonly Color Coral = new( 0.98f, 0.38f, 0.34f );
	public const float ScrollbarGutter = 14;

	/// <summary>
	/// Arm bone depth ramp, root to fingertips. Saturation stays high across the whole arc - an
	/// earlier version faded toward white at the fingertips, and desaturated colours collapse
	/// together against the dark viewport, which is exactly where the bones are densest. Hue
	/// carries the signal instead, sweeping violet through cyan to chartreuse, staying clear of
	/// Amber (weapon bones) and Coral (IK bones).
	/// </summary>
	private static readonly Color[] BoneDepthRamp =
	[
		new( 0.58f, 0.24f, 1.00f ),
		new( 0.30f, 0.45f, 1.00f ),
		new( 0.08f, 0.68f, 1.00f ),
		new( 0.10f, 0.92f, 0.94f ),
		new( 0.16f, 1.00f, 0.58f ),
		new( 0.52f, 1.00f, 0.30f ),
		new( 0.82f, 1.00f, 0.24f )
	];

	/// <summary>
	/// Samples the bone depth ramp. <paramref name="fraction"/> is 0 at the skeleton root and 1 at
	/// the deepest bone.
	/// </summary>
	public static Color BoneDepthColor( float fraction )
	{
		if ( !float.IsFinite( fraction ) )
			return BoneDepthRamp[0];

		var clamped = Math.Clamp( fraction, 0, 1 );
		var scaled = clamped * (BoneDepthRamp.Length - 1);
		var index = Math.Clamp( (int)scaled, 0, BoneDepthRamp.Length - 2 );
		return Color.Lerp(
			BoneDepthRamp[index],
			BoneDepthRamp[index + 1],
			scaled - index );
	}

	public const string PanelStyle =
		"background-color: rgb(21,23,26);" +
		"border: 1px solid rgba(255,255,255,0.075);" +
		"border-radius: 3px;";

	public const string InputStyle =
		"background-color: rgb(13,15,17);" +
		"border: 1px solid rgba(255,255,255,0.09);" +
		"border-radius: 3px;" +
		"color: rgb(224,229,234);" +
		"selection-background-color: rgb(31,126,151);" +
		"padding: 0 7px;" +
		"font-size: 11px;";

	public static Sandbox.UI.Margin ScrollCanvasMargin( float padding = 0 ) =>
		new( padding, padding, padding + ScrollbarGutter, padding );

	public static Button Button(
		string text,
		string icon,
		System.Action clicked,
		Widget? parent = null,
		bool primary = false )
	{
		var button = new WeaponAnimatorButton( text, icon, parent )
		{
			Clicked = clicked,
			FixedHeight = 28,
			Tint = primary ? Cyan * 0.65f : SurfaceRaised,
			ToolTip = text
		};
		return button;
	}

	public static Label Label( string text, Widget parent = null, bool muted = false )
	{
		var label = new Label( text, parent )
		{
			Color = muted ? Muted : Text
		};
		label.SetStyles(
			"background-color: transparent; border: none; padding: 0px;" +
			$"font-size: 11px; color: {(muted ? Muted : Text).Hex};" );
		return label;
	}

	public static Label SectionLabel(
		string text,
		Widget parent,
		Color? color = null,
		bool topMargin = false )
	{
		var label = new Label( text, parent )
		{
			Color = color ?? Muted
		};
		label.SetStyles(
			"background-color: transparent; border: none; padding: 0px;" +
			$"font-size: 9px; font-weight: 600; letter-spacing: 0.65px; color: {(color ?? Muted).Hex};" +
			(topMargin ? "margin-top: 7px;" : "") );
		return label;
	}
}

public sealed class WeaponAnimatorButton : Button
{
	public bool Flat { get; set; }

	public WeaponAnimatorButton( string text, Widget? parent = null ) : base( text, parent )
	{
		ToolTip = text;
	}

	public WeaponAnimatorButton( string text, string icon, Widget? parent = null )
		: base( text, icon, parent )
	{
		ToolTip = text;
	}

	protected override Vector2 SizeHint() => PreferredSize();
	protected override Vector2 MinimumSizeHint() => PreferredSize();
	public float PreferredWidth => PreferredSize().x;

	public void FitToContent( bool fixedWidth = false )
	{
		var width = MathF.Ceiling( PreferredWidth );
		MinimumWidth = width;
		if ( fixedWidth )
			FixedWidth = width;
		Update();
	}

	private Vector2 PreferredSize()
	{
		Paint.SetDefaultFont();
		var hasIcon = !string.IsNullOrWhiteSpace( Icon );
		var textWidth = string.IsNullOrWhiteSpace( Text ) ? 0 : Paint.MeasureText( Text ).x;
		var content = ContentLayout( 0, textWidth, hasIcon );
		return new Vector2(
			MathF.Max( 36, 20 + content.IconWidth + content.Gap + textWidth ),
			28 );
	}

	internal static (float StartX, float IconWidth, float Gap) ContentLayout(
		float centerX,
		float textWidth,
		bool hasIcon )
	{
		const float iconSize = 15;
		const float spacing = 4;
		var hasText = textWidth > 0;
		var gap = hasIcon && hasText ? spacing : 0;
		var iconWidth = hasIcon ? iconSize : 0;
		var contentWidth = textWidth + iconWidth + gap;
		return (centerX - contentWidth * 0.5f, iconWidth, gap);
	}

	protected override void OnPaint()
	{
		var color = Tint.ToHsv();
		var background = color;
		if ( Flat )
		{
			background = Color.Transparent;
			color = Enabled
				? color
				: Theme.SurfaceLightBackground.WithAlpha( 0.35f );
			if ( Enabled && Paint.HasMouseOver )
				color = color with { Value = MathF.Min( color.Value + 0.18f, 1.0f ) };
		}
		else if ( Enabled )
		{
			if ( Paint.HasPressed )
				background = color with { Value = color.Value + 0.1f };
			else if ( Paint.HasMouseOver )
				background = color with { Value = color.Value + 0.2f };
		}
		else
		{
			background = color = Theme.SurfaceLightBackground;
		}

		if ( !Flat && (!Enabled || ReadOnly) )
		{
			color = color.WithSaturation( 0.1f ).WithAlpha( 0.5f );
			background = color.WithAlpha( 0.2f );
		}

		if ( background.Alpha > 0 )
		{
			Paint.Antialiasing = true;
			Paint.ClearPen();
			Paint.SetBrush( background with
			{
				Value = background.Value + 0.04f,
				Saturation = color.Saturation * 0.8f
			} );
			Paint.DrawRect( LocalRect, 3 );
			Paint.SetBrushLinear(
				LocalRect.TopLeft,
				LocalRect.BottomRight,
				background,
				background with { Value = background.Value - 0.03f } );
			Paint.DrawRect( LocalRect.Shrink( 1 ), 3 );
		}
		else if ( !Flat )
		{
			color = Color.White.WithAlpha( 0.5f );
		}

		Paint.SetDefaultFont();
		Paint.SetPen( color with { Value = 0.99f, Saturation = color.Saturation * 0.20f } );

		const float iconSize = 15;
		var hasIcon = !string.IsNullOrWhiteSpace( Icon );
		var displayedText = Text ?? "";
		var measuredText = string.IsNullOrEmpty( displayedText )
			? Vector2.Zero
			: Paint.MeasureText( displayedText );
		var content = ContentLayout(
			LocalRect.Center.x,
			measuredText.x,
			hasIcon );
		var cursorX = content.StartX;

		if ( hasIcon )
		{
			Paint.DrawIcon(
				new Rect( cursorX, LocalRect.Center.y - iconSize * 0.5f, iconSize, iconSize ),
				Icon,
				iconSize );
			cursorX += content.IconWidth + content.Gap;
		}

		if ( measuredText.x > 0 )
		{
			Paint.DrawText(
				new Rect( cursorX, LocalRect.Top, measuredText.x, LocalRect.Height ),
				displayedText,
				TextFlag.Center );
		}
	}
}

public sealed class PanelChrome : Widget
{
	public Widget Body { get; }
	public Label TitleLabel { get; }
	public Label StatusLabel { get; }

	public PanelChrome( string title, string icon, Widget? content = null, Widget? parent = null )
		: base( parent )
	{
		SetStyles( WeaponAnimatorTheme.PanelStyle );
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		var header = new Widget( this )
		{
			FixedHeight = 34
		};
		header.SetStyles(
			"background-color: rgb(27,30,34); border: none;" );
		header.Layout = Layout.Row();
		header.Layout.Margin = new Sandbox.UI.Margin( 10, 0, 10, 0 );
		header.Layout.Spacing = 7;

		var iconLabel = new Label( icon, header );
		iconLabel.SetStyles(
			"background-color: transparent; border: none; padding: 0px;" +
			$"font-family: Material Icons; font-size: 15px; color: {WeaponAnimatorTheme.Cyan.Hex};" );
		iconLabel.FixedWidth = 18;
		header.Layout.Add( iconLabel );

		TitleLabel = WeaponAnimatorTheme.Label( title, header );
		TitleLabel.SetStyles(
			"background-color: transparent; border: none; padding: 0px;" +
			$"font-size: 10px; font-weight: 600; letter-spacing: 0.65px; color: {WeaponAnimatorTheme.Text.Hex};" );
		header.Layout.Add( TitleLabel );
		header.Layout.AddStretchCell();

		StatusLabel = WeaponAnimatorTheme.Label( "", header, true );
		header.Layout.Add( StatusLabel );
		Layout.Add( header );
		var separator = new Widget( this ) { FixedHeight = 1 };
		separator.SetStyles( "background-color: rgba(255,255,255,0.07); border: none;" );
		Layout.Add( separator );

		Body = content ?? new Widget( this );
		Body.SetStyles( "background-color: transparent; border: none;" );
		Body.Parent = this;
		Layout.Add( Body, 1 );
	}
}

public sealed class WeaponAnimatorToolbar : Widget
{
	private readonly Widget _left;
	private readonly Widget _center;
	private readonly Widget _right;
	private readonly System.Collections.Generic.List<ToolbarAction> _leftActions = [];
	private WeaponAnimatorButton? _overflowButton;

	public WeaponAnimatorToolbar( Widget? parent = null ) : base( parent )
	{
		FixedHeight = 48;
		SetStyles(
			"background-color: rgb(18,20,23);" +
			"border-bottom: 1px solid rgba(255,255,255,0.08);" );
		var grid = Layout.Grid();
		grid.Margin = new Sandbox.UI.Margin( 10, 8, 10, 8 );
		grid.HorizontalSpacing = 5;
		grid.SetColumnStretch( 1, 1, 1 );
		Layout = grid;

		_left = Section( this );
		_center = Section( this );
		_right = Section( this );
		grid.AddCell( 0, 0, _left, alignment: TextFlag.LeftCenter );
		grid.AddCell( 1, 0, _center, alignment: TextFlag.Center );
		grid.AddCell( 2, 0, _right, alignment: TextFlag.RightCenter );
	}

	public Button AddLeft(
		string text,
		string icon,
		System.Action clicked,
		bool primary = false,
		bool overflowAtNarrowWidth = false )
	{
		var button = AddButton( _left, text, icon, clicked, primary );
		_leftActions.Add( new ToolbarAction(
			(WeaponAnimatorButton)button,
			text,
			clicked,
			overflowAtNarrowWidth ) );
		return button;
	}

	public Button AddCenter( string text, string icon, System.Action clicked, bool primary = false ) =>
		AddButton( _center, text, icon, clicked, primary );

	public Button AddRight( string text, string icon, System.Action clicked, bool primary = false ) =>
		AddButton( _right, text, icon, clicked, primary );

	public void BalanceCenter()
	{
		EnsureOverflowButton();
		ApplyAvailableWidth( Width );
	}

	public void Clear()
	{
		_left.Layout.Clear( true );
		_center.Layout.Clear( true );
		_right.Layout.Clear( true );
		_left.MinimumWidth = 0;
		_center.MinimumWidth = 0;
		_right.MinimumWidth = 0;
		_leftActions.Clear();
		_overflowButton = null;
	}

	protected override void OnResize()
	{
		base.OnResize();
		ApplyAvailableWidth( Width );
	}

	internal void ApplyAvailableWidth( float availableWidth )
	{
		UpdateOverflow( availableWidth );
		var sideWidth = MathF.Max( ContentWidth( _left ), ContentWidth( _right ) );
		_left.MinimumWidth = sideWidth;
		_right.MinimumWidth = sideWidth;
	}

	private void EnsureOverflowButton()
	{
		if ( _overflowButton is not null || _leftActions.All( x => !x.OverflowAtNarrowWidth ) )
			return;

		_overflowButton = (WeaponAnimatorButton)AddButton(
			_left,
			"More",
			"more_horiz",
			ShowOverflowMenu,
			false );
		_overflowButton.Visible = false;
	}

	private void UpdateOverflow( float availableWidth )
	{
		if ( _overflowButton is null )
			return;

		var narrow = availableWidth > 0 && availableWidth < 1380;
		foreach ( var action in _leftActions.Where( x => x.OverflowAtNarrowWidth ) )
			action.Button.Visible = !narrow;
		_overflowButton.Visible = narrow;
	}

	internal bool UsesOverflow => _overflowButton?.Visible == true;

	private void ShowOverflowMenu()
	{
		if ( _overflowButton is null )
			return;

		var menu = new Menu( _overflowButton );
		foreach ( var action in _leftActions.Where( x => x.OverflowAtNarrowWidth ) )
			menu.AddOption( action.Text, null, action.Clicked );
		menu.OpenAt( _overflowButton.ScreenRect.BottomLeft );
	}

	private static float ContentWidth( Widget section ) =>
		section.Children
			.OfType<WeaponAnimatorButton>()
			.Where( x => x.Visible )
			.Sum( x => x.PreferredWidth + 5 );

	private static Button AddButton(
		Widget section,
		string text,
		string icon,
		System.Action clicked,
		bool primary )
	{
		var button = WeaponAnimatorTheme.Button( text, icon, clicked, section, primary );
		section.Layout.Add( button );
		if ( button is WeaponAnimatorButton animatorButton )
			animatorButton.FitToContent( true );
		return button;
	}

	private static Widget Section( Widget parent )
	{
		var section = new Widget( parent );
		section.SetStyles( "background-color: transparent; border: none;" );
		section.Layout = Layout.Row();
		section.Layout.Margin = 0;
		section.Layout.Spacing = 5;
		return section;
	}

	private sealed record ToolbarAction(
		WeaponAnimatorButton Button,
		string Text,
		System.Action Clicked,
		bool OverflowAtNarrowWidth );
}
sonac.sbox-animator / Code/Runtime/WeaponAnimationAsset.cs
Game library
#nullable enable annotations

using Sandbox;

namespace SboxWeaponAnimator;

[AssetType(
	Name = "Weapon Animation Project",
	Extension = "wepanim",
	Category = "Animation",
	Flags = AssetTypeFlags.NoEmbedding )]
public sealed class WeaponAnimationAsset : GameResource
{
	[Property, Hide]
	public WeaponAnimationDocument Document { get; set; } = WeaponAnimationDocument.CreateDefault();
}
sonac.sbox-animator / Editor/Services/AssetGenerationService.cs
Editor library
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class GenerationResult
{
	public bool Success { get; init; }
	public bool Cancelled { get; init; }
	public string OutputFolder { get; init; } = "";
	public ValidationReport Validation { get; init; } = new();
	public List<GenerationDiagnostic> Diagnostics { get; init; } = [];
	public List<string> GeneratedFiles { get; init; } = [];
}

public sealed record GenerationProgress(
	string Stage,
	string Detail,
	int Completed = 0,
	int Total = 0 );

public sealed class AssetGenerationService
{
	public const string GeneratorVersion = "2.1.0";
	private const string ManifestFile = "weaponanim.manifest.json";

	public async Task<GenerationResult> GenerateAsync(
		WeaponAnimationDocument document,
		Action<GenerationProgress>? progress = null,
		CancellationToken cancellationToken = default )
	{
		cancellationToken.ThrowIfCancellationRequested();
		var totalTimer = Stopwatch.StartNew();
		var previousStageMilliseconds = 0L;
		void LogStage( string stage, string execution )
		{
			var totalMilliseconds = totalTimer.ElapsedMilliseconds;
			Log.Info(
				$"[Weapon Animator] generation timing: {stage} took "
				+ $"{totalMilliseconds - previousStageMilliseconds} ms "
				+ $"({execution}, total {totalMilliseconds} ms)." );
			previousStageMilliseconds = totalMilliseconds;
		}

		WeaponAnimationDocument generationDocument;
		try
		{
			generationDocument = CreateGenerationSnapshot( document );
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] could not snapshot the project for generation: {ex}" );
			return Failed(
				new ValidationReport(),
				"generation.snapshot",
				$"Could not snapshot the project for generation: {ex.Message}" );
		}
		LogStage( "snapshot", "editor thread" );

		progress?.Invoke( new GenerationProgress( "Validate", "Validating the weapon project" ) );
		var validation = WeaponAnimationValidator.ValidateForGeneration( generationDocument );
		if ( !validation.IsValid )
			return Failed( validation, "generation.validation", "Generation is blocked by validation errors." );
		LogStage( "validation", "editor thread" );

		string outputRoot;
		string relativeRoot;
		try
		{
			outputRoot = ResolveOutputRoot( generationDocument );
			relativeRoot = WeaponSourceImporter.RelativeAssetPath( outputRoot );
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] output path resolution failed: {ex}" );
			return Failed(
				validation,
				"generation.output",
				$"Could not prepare the generated output folder: {ex.Message}" );
		}

		try
		{
			LoadOwnershipManifest( generationDocument, outputRoot );
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] could not load the ownership manifest: {ex}" );
			return Failed(
				validation,
				"ownership.manifest",
				$"Could not read the generated ownership manifest: {ex.Message}" );
		}
		LogStage( "paths and ownership", "editor thread" );

		var diagnostics = new List<GenerationDiagnostic>();
		HostSkeleton skeleton;
		Dictionary<string, string> files;
		IReadOnlyList<WeaponMaterialPipeline.GeneratedTextureCopy> textureCopies;
		try
		{
			cancellationToken.ThrowIfCancellationRequested();
			progress?.Invoke( new GenerationProgress( "Prepare", "Building generated source files" ) );
			skeleton = HostSkeletonBuilder.Build( generationDocument );
			files = await BuildFilesResponsiveAsync(
				generationDocument,
				skeleton,
				relativeRoot,
				progress,
				cancellationToken );
			textureCopies = WeaponMaterialPipeline.BuildOutputTextureCopies( generationDocument );
			LogStage(
				"source assembly",
				"sequence sampling on worker; final assembly on editor thread" );
		}
		catch ( OperationCanceledException )
		{
			return CancelledResult( validation, outputRoot );
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] could not assemble generated sources: {ex}" );
			return Failed(
				validation,
				"generation.sources",
				$"Could not assemble generated sources: {ex.Message}" );
		}
		var previousFiles = generationDocument.Manifest.Files
			.Select( x => x.RelativePath )
			.ToHashSet( StringComparer.OrdinalIgnoreCase );

		var generatedSourcePaths = files.Keys
			.Concat( textureCopies.Select( copy => copy.RelativePath ) )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();
		foreach ( var file in generatedSourcePaths )
		{
			var absolute = Path.Combine( outputRoot, file );
			if ( File.Exists( absolute ) && !previousFiles.Contains( file ) )
			{
				diagnostics.Add( Diagnostic(
					ValidationSeverity.Error,
					"ownership.conflict",
					$"Refusing to replace unowned file '{file}'.",
					absolute ) );
			}
		}

		if ( diagnostics.Any( x => x.Severity == ValidationSeverity.Error ) )
			return new GenerationResult
			{
				Success = false,
				OutputFolder = outputRoot,
				Validation = validation,
				Diagnostics = diagnostics
			};
		LogStage( "ownership conflict check", "editor thread" );

		// Generation compiles straight into the output folder. A throwaway staging copy is not
		// safe here: once ModelDoc compiles a .vmdl the asset database records its .dmx
		// dependencies, and deleting those sources afterwards leaves the asset permanently
		// out of date, which the engine then retries every frame for the rest of the session.
		// The backup and rollback below already restore owned outputs when a compile fails.
		DiscardAbandonedStage( generationDocument );
		LogStage( "abandoned-stage cleanup", "editor thread" );

		var backups = new Dictionary<string, byte[]>( StringComparer.OrdinalIgnoreCase );
		var newFiles = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		try
		{
			cancellationToken.ThrowIfCancellationRequested();
			progress?.Invoke( new GenerationProgress( "Write", "Writing persistent generation sources" ) );
			Directory.CreateDirectory( outputRoot );
			PrepareCompiledConsumersForRewrite(
				outputRoot,
				generatedSourcePaths,
				backups,
				newFiles );
			LogStage( "consumer reset", "editor thread" );
			await RunResponsiveWorkerAsync( "persistent source writes", () =>
			{
				WriteTextureCopies(
					outputRoot,
					textureCopies,
					backups,
					newFiles,
					previousFiles,
					cancellationToken );
				WriteFiles(
					outputRoot,
					files,
					backups,
					newFiles,
					previousFiles,
					cancellationToken );
				VerifyGeneratedSources( outputRoot, generatedSourcePaths );
				return true;
			}, cancellationToken );
			LogStage( "persistent source writes", "worker" );
			progress?.Invoke( new GenerationProgress(
				"Register",
				"Registering generated dependencies",
				0,
				generatedSourcePaths.Length ) );
			await RegisterGeneratedDependenciesAsync(
				outputRoot,
				generatedSourcePaths,
				progress,
				cancellationToken );
			LogStage( "dependency registration", "editor thread with per-file yields" );

			await CompileAndInspectAsync(
				generationDocument,
				outputRoot,
				relativeRoot,
				skeleton,
				diagnostics,
				progress,
				cancellationToken );
			LogStage( "compile and inspection", "asset system/resource compiler" );
			if ( diagnostics.Any( x => x.Severity == ValidationSeverity.Error ) )
				throw new InvalidOperationException( "One or more generated assets failed to compile or reload." );

			progress?.Invoke( new GenerationProgress(
				"Finalize",
				"Removing obsolete owned files" ) );
			RemoveObsoleteOwnedFiles(
				generationDocument,
				outputRoot,
				generatedSourcePaths,
				diagnostics );
			LogStage( "obsolete output cleanup", "editor thread" );
			progress?.Invoke( new GenerationProgress(
				"Finalize",
				"Hashing generated sources and writing the manifest" ) );
			var manifest = await RunResponsiveWorkerAsync( "manifest hashing", () =>
				BuildAndWriteManifest(
					generationDocument,
					outputRoot,
					generatedSourcePaths,
					files,
					diagnostics,
					backups,
					newFiles,
					cancellationToken ),
				cancellationToken );
			LogStage( "manifest hashing and write", "worker" );
			generationDocument.Manifest = manifest;
			document.Manifest = manifest;
			progress?.Invoke( new GenerationProgress( "Complete", "Generation completed" ) );

			return new GenerationResult
			{
				Success = true,
				OutputFolder = outputRoot,
				Validation = validation,
				Diagnostics = diagnostics,
				GeneratedFiles = generatedSourcePaths
					.Append( ManifestFile )
					.OrderBy( x => x )
					.ToList()
			};
		}
		catch ( OperationCanceledException )
		{
			RestoreGeneratedFiles( newFiles, backups );
			Log.Info( "[Weapon Animator] generation cancelled; owned outputs were restored." );
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Warning,
				"generation.cancelled",
				"Generation was cancelled and the previous owned outputs were restored." ) );
			return new GenerationResult
			{
				Success = false,
				Cancelled = true,
				OutputFolder = outputRoot,
				Validation = validation,
				Diagnostics = diagnostics
			};
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] generation rolled back: {ex}" );
			RestoreGeneratedFiles( newFiles, backups );

			diagnostics.Add( Diagnostic(
				ValidationSeverity.Error,
				"generation.rollback",
				$"Generation failed and owned outputs were restored: {ex.Message}" ) );
			return new GenerationResult
			{
				Success = false,
				OutputFolder = outputRoot,
				Validation = validation,
				Diagnostics = diagnostics
			};
		}
	}

	private static void WriteTextureCopies(
		string root,
		IEnumerable<WeaponMaterialPipeline.GeneratedTextureCopy> copies,
		Dictionary<string, byte[]> backups,
		HashSet<string> newFiles,
		IReadOnlySet<string> previouslyOwnedFiles,
		CancellationToken cancellationToken = default )
	{
		foreach ( var copy in copies.OrderBy(
			copy => copy.RelativePath,
			StringComparer.OrdinalIgnoreCase ) )
		{
			cancellationToken.ThrowIfCancellationRequested();
			if ( !File.Exists( copy.SourceAbsolute ) )
				throw new FileNotFoundException(
					$"Texture source for '{copy.RelativePath}' no longer exists.",
					copy.SourceAbsolute );

			var absolute = Path.Combine( root, copy.RelativePath );
			var directory = Path.GetDirectoryName( absolute );
			if ( !string.IsNullOrWhiteSpace( directory ) )
				Directory.CreateDirectory( directory );
			if ( File.Exists( absolute ) )
				backups[absolute] = File.ReadAllBytes( absolute );
			else if ( ShouldDeleteCreatedFileOnRollback(
				copy.RelativePath,
				previouslyOwnedFiles.Contains( copy.RelativePath ) ) )
				newFiles.Add( absolute );

			File.Copy( copy.SourceAbsolute, absolute, true );
		}
	}

	private static void WriteFiles(
		string root,
		IReadOnlyDictionary<string, string> files,
		Dictionary<string, byte[]>? backups = null,
		HashSet<string>? newFiles = null,
		IReadOnlySet<string>? previouslyOwnedFiles = null,
		CancellationToken cancellationToken = default )
	{
		Directory.CreateDirectory( root );

		// Sources before the .vmdl/.vanmgrph/.prefab that consume them, so the asset system never
		// sees a model whose animation files have not landed yet.
		foreach ( var name in OrderForWrite( files.Keys ) )
		{
			cancellationToken.ThrowIfCancellationRequested();
			var absolute = Path.Combine( root, name );
			var directory = Path.GetDirectoryName( absolute );
			if ( !string.IsNullOrWhiteSpace( directory ) )
				Directory.CreateDirectory( directory );
			if ( backups is not null && File.Exists( absolute ) )
				backups[absolute] = File.ReadAllBytes( absolute );
			else if ( newFiles is not null
				&& !File.Exists( absolute )
				&& backups?.ContainsKey( absolute ) != true )
			{
				if ( ShouldDeleteCreatedFileOnRollback(
					name,
					previouslyOwnedFiles?.Contains( name ) == true ) )
					newFiles.Add( absolute );
			}

			// Deliberately not an atomic write-and-rename. Replacing the file makes the engine's
			// directory watcher report it as removed and re-added, and a dependency sampled during
			// that gap is cached as "file stopped existing" — which recompiles the model forever.
			// Truncating in place only ever looks like a modification.
			File.WriteAllText( absolute, files[name], new UTF8Encoding( false ) );
		}
	}

	internal static void WriteTextSourcesForTests(
		string root,
		IReadOnlyDictionary<string, string> files ) =>
		WriteFiles( root, files );

	internal static IEnumerable<string> OrderForWrite( IEnumerable<string> paths ) =>
		paths
			.OrderBy( ConsumerWriteRank )
			.ThenBy( path => path, StringComparer.OrdinalIgnoreCase );

	private static int ConsumerWriteRank( string path )
	{
		var extension = Path.GetExtension( path ).ToLowerInvariant();
		if ( !IsCompiledConsumer( path ) )
			return 0;
		if ( IsSourceAdapter( path ) )
			return 3;
		if ( IsBootstrapHost( path ) )
			return 4;
		return extension switch
		{
			".vtex" => 1,
			".vmat" => 2,
			".vanmgrph" => 5,
			".vmdl" => 6,
			".prefab" => 7,
			_ => 8
		};
	}

	/// <summary>
	/// Extensions the asset system compiles and then tracks dependencies for. Removing one of
	/// these before the sources it consumes keeps the engine from retrying a compile against
	/// files that are about to disappear.
	/// </summary>
	private static readonly string[] CompiledConsumerExtensions =
		[".vtex", ".vmat", ".vmdl", ".vanmgrph", ".prefab"];

	private static bool IsCompiledConsumer( string path ) =>
		CompiledConsumerExtensions.Contains(
			Path.GetExtension( path ),
			StringComparer.OrdinalIgnoreCase );

	internal static bool ShouldDeleteCreatedFileOnRollback(
		string relativePath,
		bool previouslyOwned ) =>
		!previouslyOwned
		|| IsCompiledConsumer( relativePath );

	private static int ConsumerRemovalRank( string path ) =>
		IsSourceAdapter( path )
			? 3
			: IsBootstrapHost( path )
				? 4
			: Path.GetExtension( path ).ToLowerInvariant() switch
			{
				".prefab" => 7,
				".vmdl" => 6,
				".vanmgrph" => 5,
				".vmat" => 2,
				".vtex" => 1,
				_ => 0
			};

	private static bool IsSourceAdapter( string path ) =>
		path.EndsWith( "_source_adapter.vmdl", StringComparison.OrdinalIgnoreCase );

	private static bool IsBootstrapHost( string path ) =>
		path.EndsWith( "_host_bootstrap.vmdl", StringComparison.OrdinalIgnoreCase )
		|| path.EndsWith( "_vm_bootstrap.vmdl", StringComparison.OrdinalIgnoreCase );

	internal static IEnumerable<string> OrderForRemoval( IEnumerable<string> absolutePaths ) =>
		absolutePaths
			.OrderByDescending( ConsumerRemovalRank )
			.ThenBy( path => path, StringComparer.OrdinalIgnoreCase );

	/// <summary>
	/// Drop compiled consumers before rewriting their source set. This clears dependency
	/// metadata inherited from older generators without ever removing a DMX dependency.
	/// </summary>
	private static void PrepareCompiledConsumersForRewrite(
		string outputRoot,
		IEnumerable<string> relativePaths,
		Dictionary<string, byte[]> backups,
		HashSet<string> newFiles )
	{
		var consumers = relativePaths
			.Where( IsCompiledConsumer )
			.Select( path => Path.Combine( outputRoot, path ) )
			.ToArray();
		foreach ( var absolute in OrderForRemoval( consumers ) )
		{
			if ( File.Exists( absolute ) )
				backups.TryAdd( absolute, File.ReadAllBytes( absolute ) );
			else
				newFiles.Add( absolute );

			var registered = AssetSystem.FindByPath( absolute );
			if ( registered is not null )
			{
				Log.Info(
					$"[Weapon Animator] resetting registered consumer '{registered.Path}' "
					+ "before generation." );
				registered.Delete();
			}

			// Asset.Delete normally removes both files. Explicit cleanup also handles a stale
			// registry entry whose source path has already disappeared.
			foreach ( var path in new[] { absolute, absolute + "_c" } )
			{
				if ( File.Exists( path ) )
					File.Delete( path );
			}
		}
	}

	private static void VerifyGeneratedSources(
		string outputRoot,
		IEnumerable<string> relativePaths )
	{
		var missing = relativePaths
			.Where( path => !File.Exists( Path.Combine( outputRoot, path ) ) )
			.ToArray();
		if ( missing.Length > 0 )
		{
			throw new IOException(
				$"Generated source set is incomplete: {string.Join( ", ", missing )}." );
		}

		Log.Info(
			$"[Weapon Animator] verified {relativePaths.Count()} persistent generation sources "
			+ $"under '{outputRoot}'." );
	}

	private static async Task RegisterGeneratedDependenciesAsync(
		string outputRoot,
		IEnumerable<string> relativePaths,
		Action<GenerationProgress>? progress,
		CancellationToken cancellationToken )
	{
		var sources = relativePaths
			.Where( path => !IsCompiledConsumer( path ) )
			.Select( path => Path.Combine( outputRoot, path ) )
			.ToArray();
		for ( var index = 0; index < sources.Length; index++ )
		{
			cancellationToken.ThrowIfCancellationRequested();
			var absolute = sources[index];
			progress?.Invoke( new GenerationProgress(
				"Register",
				Path.GetFileName( absolute ),
				index + 1,
				sources.Length ) );
			var timer = Stopwatch.StartNew();
			var asset = AssetSystem.RegisterFile( absolute );
			Log.Info(
				$"[Weapon Animator] dependency registration '{Path.GetFileName( absolute )}' "
				+ $"took {timer.ElapsedMilliseconds} ms "
				+ $"({new FileInfo( absolute ).Length} bytes)." );
			if ( asset is null || asset.IsDeleted || !asset.HasSourceFile )
			{
				throw new IOException(
					$"The asset system did not retain generated dependency '{absolute}'." );
			}

			// RegisterFile can synchronously inspect a large DMX. Yield between dependencies so
			// repaint, progress, and cancellation are serviced before the next inspection.
			await GameTask.Yield();
		}

		Log.Info(
			$"[Weapon Animator] registered {sources.Length} persistent source dependencies "
			+ "before compiling their consumers." );
	}

	internal static void DeleteGeneratedFiles( IEnumerable<string> absolutePaths )
	{
		foreach ( var path in OrderForRemoval( absolutePaths ).ToList() )
		{
			var deletedByAssetSystem = false;
			try
			{
				var asset = AssetSystem.FindByPath( path );
				if ( asset is not null )
				{
					Log.Info(
						$"[Weapon Animator] unregistering generated asset '{asset.Path}' "
						+ $"before removing '{path}'." );
					asset.Delete();
					deletedByAssetSystem = true;
				}
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] asset-aware removal failed for '{path}': {ex.Message}" );
			}

			// Uncompiled DMX sources and verification runs have no Asset entry.
			foreach ( var target in new[] { path, path + "_c" } )
			{
				try
				{
					if ( File.Exists( target ) )
						File.Delete( target );
				}
				catch ( Exception ex )
				{
					Log.Warning( $"[Weapon Animator] could not remove '{target}': {ex.Message}" );
				}
			}

			if ( deletedByAssetSystem )
				Log.Info( $"[Weapon Animator] asset registry removal completed for '{path}'." );
		}
	}

	/// <summary>
	/// Generator versions before 1.3.0 compiled into a staging folder and then deleted it,
	/// which left the asset system recompiling assets whose sources were gone. Clear anything
	/// those runs left behind, dependants first.
	/// </summary>
	private static void DiscardAbandonedStage( WeaponAnimationDocument document )
	{
		try
		{
			var stageRoot = Path.Combine(
				WeaponSourceImporter.GetPreviewCacheRoot( document.DocumentId ),
				"generation-stage" );
			var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
			var stalePaths = new HashSet<string>( StringComparer.OrdinalIgnoreCase )
			{
				Path.Combine( stageRoot, $"{slug}_host.vmdl" ),
				Path.Combine( stageRoot, $"{slug}_vm.vmdl" ),
				Path.Combine( stageRoot, $"{slug}_host_bootstrap.vmdl" ),
				Path.Combine( stageRoot, $"{slug}_vm_bootstrap.vmdl" ),
				Path.Combine( stageRoot, $"{slug}_source.vmdl" ),
				Path.Combine( stageRoot, $"{slug}.vanmgrph" ),
				Path.Combine( stageRoot, $"v_{slug}.prefab" ),
				Path.Combine( stageRoot, $"{slug}_host_reference.dmx" )
			};
			foreach ( var clip in document.Clips )
			{
				var stem = $"{slug}_{WeaponAnimationNames.SequenceName( clip )}";
				stalePaths.Add( Path.Combine( stageRoot, $"{stem}.smd" ) );
				stalePaths.Add( Path.Combine( stageRoot, $"{stem}.dmx" ) );
			}
			var normalizedStageRoot = Path.GetFullPath( stageRoot )
				.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar )
				+ Path.DirectorySeparatorChar;
			var registeredStagePaths = AssetSystem.All
				.Where( asset => !string.IsNullOrWhiteSpace( asset.AbsolutePath ) )
				.Select( asset => Path.GetFullPath( asset.AbsolutePath ) )
				.Where( path => path.StartsWith(
					normalizedStageRoot,
					StringComparison.OrdinalIgnoreCase ) )
				.ToArray();
			stalePaths.UnionWith( registeredStagePaths );
			var stageExisted = Directory.Exists( stageRoot );
			if ( Directory.Exists( stageRoot ) )
			{
				stalePaths.UnionWith(
					Directory.GetFiles(
						stageRoot,
						"*",
						SearchOption.AllDirectories ) );
			}

			// Exact legacy paths are included even when their source files are already gone. This
			// lets Asset.Delete clear the stale registry entries that trigger on-demand retries.
			DeleteGeneratedFiles( stalePaths );
			if ( Directory.Exists( stageRoot ) )
				Directory.Delete( stageRoot, true );
			if ( stageExisted || registeredStagePaths.Length > 0 )
				Log.Info( $"[Weapon Animator] removed abandoned generation stage '{stageRoot}'." );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[Weapon Animator] could not clear the abandoned generation stage: {ex.Message}" );
		}
	}

	private static void RemoveObsoleteOwnedFiles(
		WeaponAnimationDocument document,
		string outputRoot,
		IEnumerable<string> generatedFiles,
		List<GenerationDiagnostic> diagnostics )
	{
		var retained = generatedFiles.ToHashSet( StringComparer.OrdinalIgnoreCase );
		var obsolete = document.Manifest.Files
			.Select( x => x.RelativePath )
			.Where( x => !retained.Contains( x ) )
			.Select( x => Path.Combine(
				outputRoot,
				x.Replace( '/', Path.DirectorySeparatorChar ) ) )
			.ToArray();
		if ( obsolete.Length == 0 )
			return;

		DeleteGeneratedFiles( obsolete );
		foreach ( var path in obsolete )
		{
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Info,
				"ownership.obsolete_removed",
				$"Removed obsolete generated file '{Path.GetFileName( path )}'.",
				path ) );
		}
	}

	public static string GetOutputFolder( WeaponAnimationDocument document ) =>
		ResolveOutputRoot( document );

	internal static Dictionary<string, string> BuildFiles(
		WeaponAnimationDocument document,
		HostSkeleton skeleton,
		string relativeRoot,
		Action<GenerationProgress>? progress = null,
		CancellationToken cancellationToken = default )
	{
		cancellationToken.ThrowIfCancellationRequested();
		var preparedClips = BuildClipSources(
			document,
			skeleton,
			progress,
			cancellationToken );
		return AssembleFiles( document, skeleton, relativeRoot, preparedClips );
	}

	private static async Task<Dictionary<string, string>> BuildFilesResponsiveAsync(
		WeaponAnimationDocument document,
		HostSkeleton skeleton,
		string relativeRoot,
		Action<GenerationProgress>? progress,
		CancellationToken cancellationToken )
	{
		var orderedClips = GeneratedClips( document )
			.OrderBy( clip => clip.Name )
			.ToArray();
		var preparedClips = new List<PreparedClipSource>( orderedClips.Length );
		for ( var clipIndex = 0; clipIndex < orderedClips.Length; clipIndex++ )
		{
			cancellationToken.ThrowIfCancellationRequested();
			var clip = orderedClips[clipIndex];
			progress?.Invoke( new GenerationProgress(
				"Sequences",
				$"Sampling {clip.Name}",
				clipIndex + 1,
				orderedClips.Length ) );
			var source = await RunResponsiveWorkerAsync( $"sequence {clip.Name}", () =>
				DmxWriter.WriteAnimation(
					document,
					skeleton,
					clip,
					cancellationToken ),
				cancellationToken );
			preparedClips.Add( new PreparedClipSource( clip, source ) );
		}

		cancellationToken.ThrowIfCancellationRequested();
		return AssembleFiles( document, skeleton, relativeRoot, preparedClips );
	}

	private static IReadOnlyList<PreparedClipSource> BuildClipSources(
		WeaponAnimationDocument document,
		HostSkeleton skeleton,
		Action<GenerationProgress>? progress,
		CancellationToken cancellationToken )
	{
		var orderedClips = GeneratedClips( document )
			.OrderBy( clip => clip.Name )
			.ToArray();
		var preparedClips = new List<PreparedClipSource>( orderedClips.Length );
		for ( var clipIndex = 0; clipIndex < orderedClips.Length; clipIndex++ )
		{
			cancellationToken.ThrowIfCancellationRequested();
			var clip = orderedClips[clipIndex];
			progress?.Invoke( new GenerationProgress(
				"Sequences",
				$"Sampling {clip.Name}",
				clipIndex + 1,
				orderedClips.Length ) );
			preparedClips.Add( new PreparedClipSource(
				clip,
				DmxWriter.WriteAnimation(
					document,
					skeleton,
					clip,
					cancellationToken ) ) );
		}
		return preparedClips;
	}

	private static Dictionary<string, string> AssembleFiles(
		WeaponAnimationDocument document,
		HostSkeleton skeleton,
		string relativeRoot,
		IReadOnlyList<PreparedClipSource> preparedClips )
	{
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		var files = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
		var referenceName = $"{slug}_host_reference.dmx";
		var bootstrapHostName = $"{slug}_vm_bootstrap.vmdl";
		var hostName = $"{slug}_vm.vmdl";
		var graphName = $"{slug}.vanmgrph";
		var prefabName = $"v_{slug}.prefab";
		files[referenceName] = DmxWriter.WriteReference( skeleton );
		foreach ( var materialFile in WeaponMaterialPipeline.BuildOutputTextFiles(
			document,
			relativeRoot ) )
		{
			files[materialFile.Key] = materialFile.Value;
		}

		var clipSources = new List<(WeaponAnimationClip Clip, string Source)>();
		foreach ( var preparedClip in preparedClips )
		{
			var clip = preparedClip.Clip;
			var clipName =
				$"{slug}_sequence_{WeaponAnimationNames.SequenceName( clip )}.dmx";
			files[clipName] = preparedClip.Source;
			clipSources.Add( (clip, $"{relativeRoot}/{clipName}") );
		}

		var graphPath = document.Output.GenerateGraph && document.Graph.GenerateGraph
			? $"{relativeRoot}/{graphName}"
			: "";
		var placement = WeaponAnimationMath.Compose(
			document.Calibration.PhysicalTransform,
			document.Calibration.FramingTransform );
		var excludedBranches = ExcludedBranchRoots( document ).ToArray();
		var materialRemaps = WeaponMaterialPipeline.OutputRemaps(
			document,
			relativeRoot );
		HostWeaponMesh? weaponMesh = null;
		var baseModelPath = "";
		if ( IsVmdlSource( document ) )
		{
			var adapterName = $"{slug}_source_adapter.vmdl";
			var sourceAbsolute = ResolveSourceAbsolutePath( document.Source.SourcePath );
			if ( !File.Exists( sourceAbsolute ) )
				throw new FileNotFoundException(
					"The imported VMDL source no longer exists.",
					sourceAbsolute );
			files[adapterName] = ModelDocWriter.WriteVmdlSourceAdapter(
				File.ReadAllText( sourceAbsolute ),
				document.Source.SourceRootBoneName,
				excludedBranches,
				placement );
			baseModelPath = $"{relativeRoot}/{adapterName}";
		}
		else
		{
			weaponMesh = new HostWeaponMesh(
				ResolveEmbeddableSourcePath( document ),
				document.Source.SourceRootBoneName,
				placement,
				excludedBranches,
				materialRemaps );
		}
		var hostSource = ModelDocWriter.WriteHost(
			$"{relativeRoot}/{referenceName}",
			clipSources,
			graphPath,
			skeleton.Bones.Select( bone => bone.Name ),
			weaponMesh,
			BuildHostAttachments( document, skeleton ),
			baseModelPath,
			materialRemaps );
		files[hostName] = hostSource;

		if ( document.Output.GenerateGraph && document.Graph.GenerateGraph )
		{
			// The graph previews a permanent graph-free sibling, breaking the otherwise circular
			// host -> graph -> preview-host compile dependency.
			files[bootstrapHostName] = ModelDocWriter.WriteHost(
				$"{relativeRoot}/{referenceName}",
				clipSources,
				"",
				skeleton.Bones.Select( bone => bone.Name ),
				weaponMesh,
				BuildHostAttachments( document, skeleton ),
				baseModelPath,
				materialRemaps );
			files[graphName] = AnimGraphWriter.Write(
				document,
				$"{relativeRoot}/{bootstrapHostName}" );
		}

		if ( document.Output.GeneratePrefab )
			files[prefabName] = PrefabWriter.Write(
				document,
				$"{relativeRoot}/{hostName}" );

		return files;
	}

	private sealed record PreparedClipSource(
		WeaponAnimationClip Clip,
		string Source );

	private static async Task<T> RunResponsiveWorkerAsync<T>(
		string stage,
		Func<T> work,
		CancellationToken cancellationToken )
	{
		var worker = GameTask.RunInThreadAsync( work );
		var timer = Stopwatch.StartNew();
		var nextHeartbeat = 2000L;
		while ( !worker.IsCompleted )
		{
			// Explicitly return to the editor task loop while the worker owns CPU-heavy text work.
			await GameTask.DelayRealtime( 16 );
			if ( timer.ElapsedMilliseconds < nextHeartbeat )
				continue;

			Log.Info(
				$"[Weapon Animator] worker heartbeat: '{stage}' is still running after "
				+ $"{timer.ElapsedMilliseconds} ms; editor thread "
				+ $"{Environment.CurrentManagedThreadId} is pumping." );
			nextHeartbeat += 2000;
		}

		// Managed loops observe cancellation internally. Native calls cannot be interrupted, so
		// await their return before cancellation is allowed to start rollback.
		var result = await worker;
		cancellationToken.ThrowIfCancellationRequested();
		return result;
	}

	private static string ResolveEmbeddableSourcePath( WeaponAnimationDocument document )
	{
		var extension = Path.GetExtension( document.Source.SourcePath );
		if ( WeaponSourceFormatSupport.CanGenerate( document.Source.SourcePath )
			&& !extension.Equals( ".vmdl", StringComparison.OrdinalIgnoreCase ) )
			return document.Source.SourcePath;

		throw new InvalidOperationException(
			$"The standard single-renderer viewmodel currently needs an FBX, DMX, OBJ, or VMDL render source; "
			+ $"'{extension}' cannot be embedded by ModelDoc." );
	}

	internal static IReadOnlyList<WeaponAnimationClip> GeneratedClips(
		WeaponAnimationDocument document ) =>
		document.Clips
			.Where( clip => clip.Readiness != ClipReadiness.NotStarted )
			.ToArray();

	private static bool IsVmdlSource( WeaponAnimationDocument document ) =>
		Path.GetExtension( document.Source.SourcePath )
			.Equals( ".vmdl", StringComparison.OrdinalIgnoreCase );

	private static string ResolveSourceAbsolutePath( string sourcePath ) =>
		Path.IsPathRooted( sourcePath )
			? Path.GetFullPath( sourcePath )
			: Path.GetFullPath( Path.Combine(
				WeaponSourceImporter.GetContentRoot(),
				sourcePath.TrimStart( '/', '\\' ) ) );

	private static IEnumerable<HostAttachment> BuildHostAttachments(
		WeaponAnimationDocument document,
		HostSkeleton skeleton )
	{
		var sourceRoot = document.Rig.FindBone( document.Rig.SourceSkeletonRootId );
		var compilerModel = skeleton.BuildCompilerBindModelTransforms();
		var placement = WeaponAnimationMath.Compose(
			document.Calibration.PhysicalTransform,
			document.Calibration.FramingTransform );
		foreach ( var anchor in document.Calibration.Anchors.Where( x =>
			x.Kind is AnchorKind.Muzzle or AnchorKind.Eject or AnchorKind.Custom ) )
		{
			var parent = document.Rig.FindBone( anchor.BoneName ) ?? sourceRoot;
			if ( parent is null )
				continue;

			var parentName = parent.Id.Equals(
				document.Rig.SourceSkeletonRootId,
				StringComparison.OrdinalIgnoreCase )
					? "weapon_root"
					: parent.Name;
			if ( !compilerModel.TryGetValue( parentName, out var compiledParent ) )
				continue;

			var anchorModelPosition = placement.PointToWorld( anchor.LocalPosition );
			var anchorModelRotation = placement.Rotation * anchor.LocalRotation;
			yield return new HostAttachment(
				WeaponAnimationNames.AttachmentName( anchor ),
				parentName,
				compiledParent.PointToLocal( anchorModelPosition ),
				compiledParent.Rotation.Inverse * anchorModelRotation );
		}
	}

	private static IEnumerable<string> ExcludedBranchRoots(
		WeaponAnimationDocument document )
	{
		foreach ( var bone in document.Rig.Bones.Where( x =>
			x.Inclusion == WeaponBoneInclusion.Excluded ) )
		{
			var parent = document.Rig.FindBone( bone.ParentId );
			if ( parent is null || parent.Inclusion != WeaponBoneInclusion.Excluded )
				yield return string.IsNullOrWhiteSpace( bone.OriginalName )
					? bone.Name
					: bone.OriginalName;
		}
	}

	/// <summary>
	/// Seconds to let a single generated asset finish compiling before treating it as failed.
	/// </summary>
	private const float CompileTimeoutSeconds = 120.0f;
	private const float HostReloadTimeoutSeconds = 20.0f;

	/// <summary>
	/// Asset compilation is main-thread-only. The resource compiler may hold the editor while the
	/// request runs, then this method polls until the replacement asset is live.
	/// </summary>
	internal static async Task<bool> WaitForCompileAsync(
		Asset asset,
		string sourceAbsolute,
		CancellationToken cancellationToken = default )
	{
		cancellationToken.ThrowIfCancellationRequested();
		var queued = asset.Compile( true );
		Log.Info(
			$"[Weapon Animator] compile request for '{asset.Path}': queued={queued}, "
			+ $"deleted={asset.IsDeleted}, canRecompile={asset.CanRecompile}, "
			+ $"hasSource={asset.HasSourceFile}." );

		var deadline = DateTime.UtcNow.AddSeconds( CompileTimeoutSeconds );
		var nextProgressLog = DateTime.UtcNow.AddSeconds( 5 );
		var retriedLiveAsset = false;
		while ( true )
		{
			cancellationToken.ThrowIfCancellationRequested();
			// Asset.Delete followed by RegisterFile can leave callers holding the retired managed
			// wrapper while the directory watcher has already created and compiled a replacement.
			var current = AssetSystem.FindByPath( sourceAbsolute ) ?? asset;
			var compiledAbsolute = FreshCompiledArtifact( current, sourceAbsolute );
			if ( !string.IsNullOrWhiteSpace( compiledAbsolute ) )
			{
				if ( !current.IsCompiledAndUpToDate || !current.HasCompiledFile )
				{
					Log.Info(
						$"[Weapon Animator] accepted fresh compiled artifact '{compiledAbsolute}' "
						+ $"while the managed asset flags for '{current.Path}' caught up." );
				}
				return true;
			}

			if ( current.IsCompileFailed )
				return false;
			if ( current.IsCompiledAndUpToDate && current.HasCompiledFile )
			{
				Log.Error(
					$"[Weapon Animator] '{current.Path}' reports compiled but no fresh artifact "
					+ $"exists for '{sourceAbsolute}'." );
				return false;
			}

			if ( !retriedLiveAsset
				&& !ReferenceEquals( current, asset )
				&& current.CanRecompile )
			{
				retriedLiveAsset = true;
				var liveQueued = current.Compile( true );
				Log.Info(
					$"[Weapon Animator] retried compile through the live asset entry "
					+ $"'{current.Path}': queued={liveQueued}." );
			}
			if ( DateTime.UtcNow > deadline )
			{
				Log.Warning(
					$"[Weapon Animator] '{current.Path}' did not finish compiling within "
					+ $"{CompileTimeoutSeconds:0} seconds." );
				return false;
			}
			if ( DateTime.UtcNow >= nextProgressLog )
			{
				Log.Info(
					$"[Weapon Animator] waiting for '{current.Path}': "
					+ $"compiled={current.IsCompiled}, upToDate={current.IsCompiledAndUpToDate}, "
					+ $"hasCompiledFile={current.HasCompiledFile}, deleted={current.IsDeleted}, "
					+ $"canRecompile={current.CanRecompile}, hasSource={current.HasSourceFile}, "
					+ $"sourceExists={File.Exists( sourceAbsolute )}." );
				nextProgressLog = DateTime.UtcNow.AddSeconds( 5 );
			}

			await Task.Delay( 16, cancellationToken );
		}
	}

	private static string FreshCompiledArtifact(
		Asset asset,
		string sourceAbsolute )
	{
		var candidates = new HashSet<string>( StringComparer.OrdinalIgnoreCase )
		{
			sourceAbsolute + "_c"
		};
		try
		{
			var reported = asset.GetCompiledFile( true );
			if ( !string.IsNullOrWhiteSpace( reported ) )
				candidates.Add( reported );
		}
		catch
		{
			// A retired asset wrapper can throw while its replacement is registered.
		}

		return candidates.FirstOrDefault( compiled =>
			IsFreshCompiledArtifact( sourceAbsolute, compiled ) ) ?? "";
	}

	internal static bool IsFreshCompiledArtifact(
		string sourceAbsolute,
		string compiledAbsolute )
	{
		if ( !File.Exists( sourceAbsolute ) || !File.Exists( compiledAbsolute ) )
			return false;

		// Wine and the mounted filesystem can round source/compiled timestamps differently.
		return File.GetLastWriteTimeUtc( compiledAbsolute )
			>= File.GetLastWriteTimeUtc( sourceAbsolute ).AddSeconds( -2 );
	}

	private static async Task CompileAndInspectAsync(
		WeaponAnimationDocument document,
		string outputRoot,
		string relativeRoot,
		HostSkeleton skeleton,
		List<GenerationDiagnostic> diagnostics,
		Action<GenerationProgress>? progress,
		CancellationToken cancellationToken )
	{
		var slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );
		var bootstrapHostFile = $"{slug}_vm_bootstrap.vmdl";
		var hostFile = $"{slug}_vm.vmdl";
		var graphEnabled = document.Output.GenerateGraph && document.Graph.GenerateGraph;
		var hostAbsolute = Path.Combine( outputRoot, hostFile );
		var materialSources = WeaponMaterialPipeline.BuildOutputTextFiles(
			document,
			relativeRoot );
		var materialCount = materialSources.Keys.Count( path =>
			Path.GetExtension( path ).Equals( ".vmat", StringComparison.OrdinalIgnoreCase ) );
		var compileTotal = materialCount
			+ (IsVmdlSource( document ) ? 1 : 0)
			+ (graphEnabled ? 3 : 1)
			+ (document.Output.GeneratePrefab ? 1 : 0);
		var compileIndex = 0;

		async Task<bool> Compile( string file, string? description = null )
		{
			cancellationToken.ThrowIfCancellationRequested();
			var timer = Stopwatch.StartNew();
			compileIndex++;
			progress?.Invoke( new GenerationProgress(
				"Compile",
				description ?? file,
				compileIndex,
				compileTotal ) );
			var absolute = Path.Combine( outputRoot, file );
			var registrationTimer = Stopwatch.StartNew();
			var asset = AssetSystem.RegisterFile( absolute );
			Log.Info(
				$"[Weapon Animator] consumer registration '{description ?? file}' took "
				+ $"{registrationTimer.ElapsedMilliseconds} ms." );
			if ( asset is null )
			{
				Log.Error( $"[Weapon Animator] could not register '{absolute}' as an asset." );
				diagnostics.Add( Diagnostic(
					ValidationSeverity.Error,
					"compile.failed",
					$"Could not register '{file}' with the asset system.",
					absolute ) );
				return false;
			}

			if ( await WaitForCompileAsync(
				asset,
				absolute,
				cancellationToken ) )
			{
				Log.Info(
					$"[Weapon Animator] generation timing: compiled "
					+ $"'{description ?? file}' in {timer.ElapsedMilliseconds} ms." );
				diagnostics.Add( Diagnostic(
					ValidationSeverity.Info,
					"compile.ok",
					$"Compiled '{description ?? file}'.",
					asset.Path ) );
				return true;
			}

			Log.Error( $"[Weapon Animator] failed to compile '{absolute}'." );
			Log.Error(
				$"[Weapon Animator] generation timing: failed '{description ?? file}' "
				+ $"after {timer.ElapsedMilliseconds} ms." );
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Error,
				"compile.failed",
				$"Failed to compile '{description ?? file}'.",
				absolute ) );
			return false;
		}

		foreach ( var materialFile in materialSources.Keys
			.Where( path => Path.GetExtension( path ).Equals(
				".vmat",
				StringComparison.OrdinalIgnoreCase ) )
			.OrderBy( path => path, StringComparer.OrdinalIgnoreCase ) )
		{
			cancellationToken.ThrowIfCancellationRequested();
			if ( !await Compile( materialFile, $"{materialFile} material" ) )
				return;
		}

		if ( IsVmdlSource( document )
			&& !await Compile(
				$"{slug}_source_adapter.vmdl",
				$"{slug}_source_adapter.vmdl source adapter" ) )
			return;

		var hostCompiled = false;
		if ( graphEnabled )
		{
			var graphPath = $"{relativeRoot}/{slug}.vanmgrph";
			var linkedGraph = $"anim_graph_name = \"{graphPath}\"";
			var finalHostSource = File.ReadAllText( hostAbsolute );
			if ( !finalHostSource.Contains( linkedGraph, StringComparison.Ordinal ) )
			{
				diagnostics.Add( Diagnostic(
					ValidationSeverity.Error,
					"compile.graph_link",
					"The final host source does not contain its generated AnimGraph link.",
					hostAbsolute ) );
				return;
			}

			var bootstrapCompiled = await Compile(
				bootstrapHostFile,
				$"{bootstrapHostFile} graph preview" );
			var graphCompiled = await Compile( $"{slug}.vanmgrph" );
			if ( bootstrapCompiled && graphCompiled )
				hostCompiled = await Compile( hostFile, $"{hostFile} with AnimGraph" );
		}
		else
		{
			hostCompiled = await Compile( hostFile );
		}
		if ( !hostCompiled )
			return;

		var hostPath = $"{relativeRoot}/{slug}_vm.vmdl";
		var host = await ReloadGeneratedHostAsync(
			hostAbsolute,
			hostPath,
			skeleton,
			cancellationToken );
		var missingRequiredBones = host is null || host.IsError
			? skeleton.Bones.Select( bone => bone.Name ).ToArray()
			: MissingRequiredBones( skeleton, host );
		if ( host is null || host.IsError || missingRequiredBones.Length > 0 )
		{
			Log.Error(
				$"[Weapon Animator] host '{hostPath}' reloaded with {host?.BoneCount ?? 0} bones "
				+ $"(required {skeleton.Bones.Count}, missing {missingRequiredBones.Length}, "
				+ $"error: {host?.IsError.ToString() ?? "not loaded"})." );
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Error,
				"inspect.host",
				$"Host reload is missing required bones: "
				+ $"{string.Join( ", ", missingRequiredBones.Take( 8 ) )}.",
				hostPath ) );
			return;
		}

		var additionalBones = AdditionalCompiledBones( skeleton, host );
		if ( additionalBones.Length > 0 )
		{
			Log.Info(
				$"[Weapon Animator] compiled host includes {additionalBones.Length} additional "
				+ $"source-mesh bone entries: {string.Join( ", ", additionalBones.Take( 16 ) )}." );
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Warning,
				"inspect.additional_bones",
				$"The visible source mesh contributed {additionalBones.Length} additional compiled "
				+ "bone entries; all required animation-host bones are present.",
				hostPath ) );
		}

		var normalizedScaleBones = CountCompiledScaleNormalizations( skeleton, host );
		if ( normalizedScaleBones > 0 )
		{
			Log.Info(
				$"[Weapon Animator] compiler normalized bind scale on {normalizedScaleBones} "
				+ "bone(s); calibrated mesh scale remains baked into the generated model." );
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Info,
				"inspect.bind_scale_normalized",
				$"ModelDoc normalized bind scale on {normalizedScaleBones} bone(s).",
				hostPath ) );
		}

		var bindIssues = InspectCompiledBindPose( skeleton, host );
		if ( bindIssues.Count > 0 )
		{
			foreach ( var issue in bindIssues.Take( 8 ) )
			{
				var expectedBone = skeleton.ByName[issue.BoneName];
				var actualBone = host.Bones.GetBone( issue.BoneName );
				Log.Error(
					$"[Weapon Animator] compiled bind mismatch '{issue.BoneName}': "
					+ $"expectedParent='{expectedBone.ParentName}', "
					+ $"actualParent='{actualBone?.Parent?.Name ?? ""}', "
					+ $"position={issue.PositionDelta:0.######}, "
					+ $"rotation={issue.RotationDelta:0.######}, "
					+ $"scale={issue.ScaleDelta:0.######}, "
					+ $"expected={DescribeTransform( issue.Expected )}, "
					+ $"actual={DescribeTransform( issue.Actual )}." );
			}
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Error,
				"inspect.bind_pose",
				$"Compiled host bind pose differs from the authored host on {bindIssues.Count} "
				+ $"bone(s); first mismatch: {bindIssues[0].BoneName}.",
				hostPath ) );
			return;
		}

		LogRotatingWeaponPivotDiagnostics( document, skeleton, host, diagnostics, hostPath );

		var sequenceNames = Enumerable.Range( 0, host.AnimationCount )
			.Select( host.GetAnimationName )
			.Where( name => !string.IsNullOrWhiteSpace( name ) )
			.ToHashSet( StringComparer.OrdinalIgnoreCase );
		var expectedSequences = GeneratedClips( document )
			.Select( WeaponAnimationNames.SequenceName )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.ToArray();
		var missingSequences = expectedSequences
			.Where( name => !sequenceNames.Contains( name ) )
			.ToArray();
		Log.Info(
			$"[Weapon Animator] host '{hostPath}' exposes {host.AnimationCount} animation "
			+ $"sequence(s): {string.Join( ", ", sequenceNames.OrderBy( x => x ) )}." );
		if ( missingSequences.Length > 0 )
		{
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Error,
				"inspect.sequences",
				$"Compiled host is missing generated animation sequences: "
				+ $"{string.Join( ", ", missingSequences )}.",
				hostPath ) );
			return;
		}
		diagnostics.Add( Diagnostic(
			ValidationSeverity.Info,
			"inspect.sequences",
			$"Host exposes all {expectedSequences.Length} generated animation sequences.",
			hostPath ) );

		if ( graphEnabled )
		{
			var graph = host.AnimGraph;
			var requiredParameters = new[] { "b_attack", "b_reload", "b_empty" };
			var missing = graph is null || graph.IsError
				? requiredParameters
				: requiredParameters
					.Where( name => !graph.TryGetParameterIndex( name, out _ ) )
					.ToArray();
			if ( graph is null || graph.IsError || missing.Length > 0 )
			{
				Log.Error(
					$"[Weapon Animator] host '{hostPath}' has no usable AnimGraph parameters. "
					+ $"Graph loaded: {graph is not null}, graph error: {graph?.IsError.ToString() ?? "n/a"}, "
					+ $"missing: {string.Join( ", ", missing )}." );
				diagnostics.Add( Diagnostic(
					ValidationSeverity.Error,
					"inspect.animgraph",
					$"Host reload is missing AnimGraph parameters: {string.Join( ", ", missing )}.",
					hostPath ) );
			}
			else
			{
				diagnostics.Add( Diagnostic(
					ValidationSeverity.Info,
					"inspect.animgraph",
					$"Host exposes {graph.ParamCount} AnimGraph parameters, including the Facepunch firearm profile.",
					hostPath ) );
			}
		}

		// Compile the prefab only after its model and graph have reloaded successfully. This keeps
		// a transient model-cache delay from producing and then rolling back a dependent prefab.
		if ( document.Output.GeneratePrefab )
		{
			cancellationToken.ThrowIfCancellationRequested();
			await Compile( $"v_{slug}.prefab" );
		}
	}

	private static async Task<Model?> ReloadGeneratedHostAsync(
		string hostAbsolute,
		string hostPath,
		HostSkeleton skeleton,
		CancellationToken cancellationToken )
	{
		var expectedBoneCount = skeleton.Bones.Count;
		var started = DateTime.UtcNow;
		var deadline = started.AddSeconds( HostReloadTimeoutSeconds );
		var nextProgressLog = started.AddSeconds( 2 );
		Model? last = null;

		while ( DateTime.UtcNow <= deadline )
		{
			cancellationToken.ThrowIfCancellationRequested();
			var asset = AssetSystem.FindByPath( hostAbsolute );
			// Successful compilation can precede resource hotload by several frames. Reacquire
			// both the Asset and Model until the replacement resource is visible.
			await Task.Delay( 50, cancellationToken );
			try
			{
				last = asset?.LoadResource<Model>() ?? Model.Load( hostPath );
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] host reload attempt for '{hostPath}' threw: {ex.Message}" );
				last = null;
			}

			var missing = last is null || last.IsError
				? expectedBoneCount
				: MissingRequiredBones( skeleton, last ).Length;
			if ( last is not null && !last.IsError && missing == 0 )
			{
				var elapsed = (DateTime.UtcNow - started).TotalMilliseconds;
				Log.Info(
					$"[Weapon Animator] host '{hostPath}' reloaded with "
					+ $"{last.BoneCount} bones after {elapsed:0} ms." );
				return last;
			}

			if ( DateTime.UtcNow >= nextProgressLog )
			{
				Log.Info(
					$"[Weapon Animator] waiting for host resource '{hostPath}': "
					+ $"bones={last?.BoneCount ?? 0} (required {expectedBoneCount}, missing {missing}), "
					+ $"error={last?.IsError.ToString() ?? "not loaded"}, "
					+ $"assetPresent={asset is not null}, "
					+ $"compiledArtifact={File.Exists( hostAbsolute + "_c" )}." );
				nextProgressLog = DateTime.UtcNow.AddSeconds( 2 );
			}
		}

		return last;
	}

	private static string[] MissingRequiredBones(
		HostSkeleton skeleton,
		Model host ) =>
		skeleton.Bones
			.Where( expected => host.Bones.GetBone( expected.Name ) is null )
			.Select( expected => expected.Name )
			.ToArray();

	private static string[] AdditionalCompiledBones(
		HostSkeleton skeleton,
		Model host )
	{
		var expected = skeleton.Bones
			.Select( bone => bone.Name )
			.ToHashSet( StringComparer.OrdinalIgnoreCase );
		var additionalNames = host.Bones.AllBones
			.Where( bone => !expected.Contains( bone.Name ) )
			.Select( bone => bone.Name );
		var duplicateNames = host.Bones.AllBones
			.GroupBy( bone => bone.Name, StringComparer.OrdinalIgnoreCase )
			.Where( group => group.Count() > 1 )
			.Select( group => $"{group.Key} ×{group.Count()}" );
		return additionalNames
			.Concat( duplicateNames )
			.OrderBy( name => name, StringComparer.OrdinalIgnoreCase )
			.ToArray();
	}

	internal sealed record CompiledBindIssue(
		string BoneName,
		float PositionDelta,
		float RotationDelta,
		float ScaleDelta,
		Transform Expected,
		Transform Actual );

	private static IReadOnlyList<CompiledBindIssue> InspectCompiledBindPose(
		HostSkeleton skeleton,
		Model host,
		float positionTolerance = 0.02f,
		float rotationTolerance = 0.005f )
	{
		var issues = new List<CompiledBindIssue>();
		var compiledExpectation = skeleton.BuildCompilerBindModelTransforms();
		foreach ( var expected in skeleton.Bones )
		{
			var actualBone = host.Bones.GetBone( expected.Name );
			var expectedTransform = compiledExpectation[expected.Name];
			if ( actualBone is null )
			{
				issues.Add( new CompiledBindIssue(
					expected.Name,
					float.PositiveInfinity,
					float.PositiveInfinity,
					float.PositiveInfinity,
					expectedTransform,
					Transform.Zero ) );
				continue;
			}

			var actual = actualBone.LocalTransform;
			var positionDelta = expectedTransform.Position.Distance( actual.Position );
			var rotationDelta = MathF.Max(
				(expectedTransform.Rotation.Forward - actual.Rotation.Forward).Length,
				(expectedTransform.Rotation.Up - actual.Rotation.Up).Length );
			var scaleDelta = (expectedTransform.Scale - actual.Scale).Length;
			if ( positionDelta > positionTolerance
				|| rotationDelta > rotationTolerance )
			{
				issues.Add( new CompiledBindIssue(
					expected.Name,
					positionDelta,
					rotationDelta,
					scaleDelta,
					expectedTransform,
					actual ) );
			}
		}

		return issues;
	}

	private static int CountCompiledScaleNormalizations(
		HostSkeleton skeleton,
		Model host,
		float scaleTolerance = 0.005f ) =>
		skeleton.Bones.Count( expected =>
		{
			var actual = host.Bones.GetBone( expected.Name );
			return actual is not null
				&& (expected.BindModelTransform.Scale - actual.LocalTransform.Scale).Length
					> scaleTolerance;
		} );

	private static void LogRotatingWeaponPivotDiagnostics(
		WeaponAnimationDocument document,
		HostSkeleton skeleton,
		Model host,
		List<GenerationDiagnostic> diagnostics,
		string hostPath )
	{
		var compilerLocal = skeleton.BuildCompilerBindLocalTransforms();
		var targets = document.Clips
			.SelectMany( clip => clip.Tracks )
			.Where( track => skeleton.ByName.TryGetValue( track.Target, out var bone )
				&& bone.IsWeaponBone
				&& track.Keys.Any( key => RotationDiffers(
					key.Rotation,
					skeleton.GetBindLocal( bone ).Rotation ) ) )
			.Select( track => track.Target )
			.Distinct( StringComparer.OrdinalIgnoreCase )
			.OrderBy( name => name, StringComparer.OrdinalIgnoreCase )
			.ToArray();

		foreach ( var target in targets )
		{
			var expected = skeleton.ByName[target];
			var actual = host.Bones.GetBone( target );
			var actualParent = actual?.Parent;
			var actualLocal = actual is null
				? Transform.Zero
				: actualParent is null
					? actual.LocalTransform
					: actualParent.LocalTransform.ToLocal( actual.LocalTransform );
			Log.Info(
				$"[Weapon Animator] rotating weapon pivot '{target}': "
				+ $"parent expected='{expected.ParentName}', actual='{actualParent?.Name ?? ""}', "
				+ $"authoredLocal={DescribeTransform( skeleton.GetBindLocal( expected ) )}, "
				+ $"exportLocal={DescribeTransform( compilerLocal[target] )}, "
				+ $"compiledLocal={DescribeTransform( actualLocal )}, "
				+ $"compiledModel={DescribeTransform( actual?.LocalTransform ?? Transform.Zero )}." );
		}

		if ( targets.Length > 0 )
		{
			diagnostics.Add( Diagnostic(
				ValidationSeverity.Info,
				"inspect.rotation_pivots",
				$"Verified {targets.Length} rotation-driven weapon bone pivot(s) in compiled bind space.",
				hostPath ) );
		}
	}

	private static bool RotationDiffers(
		Rotation left,
		Rotation right,
		float tolerance = 0.001f ) =>
		(left.Forward - right.Forward).Length > tolerance
			|| (left.Up - right.Up).Length > tolerance;

	private static string DescribeTransform( Transform transform ) =>
		$"pos({transform.Position.x:0.####},{transform.Position.y:0.####},{transform.Position.z:0.####}) "
		+ $"rot({transform.Rotation.x:0.####},{transform.Rotation.y:0.####},"
		+ $"{transform.Rotation.z:0.####},{transform.Rotation.w:0.####}) "
		+ $"scale({transform.Scale.x:0.####},{transform.Scale.y:0.####},"
		+ $"{transform.Scale.z:0.####})";

	private static string ResolveOutputRoot( WeaponAnimationDocument document )
	{
		return ResolveOutputRootForContentRoot(
			document,
			WeaponSourceImporter.GetContentRoot() );
	}

	internal static string ResolveOutputRootForContentRoot(
		WeaponAnimationDocument document,
		string contentRoot )
	{
		if ( string.IsNullOrWhiteSpace( contentRoot ) )
			throw new InvalidOperationException( "The current project's Assets directory is unavailable." );

		document.Output ??= new OutputSettings
		{
			AssetName = WeaponAnimationDocument.Slugify( document.Name )
		};
		var configured = string.IsNullOrWhiteSpace( document.Output.OutputFolder )
			? document.Output.GetDefaultRelativeFolder()
			: document.Output.OutputFolder;
		configured = configured.Trim().Replace( '\\', '/' ).TrimStart( '/' );
		if ( string.IsNullOrWhiteSpace( configured ) )
			configured = document.Output.GetDefaultRelativeFolder();
		if ( configured.Length >= 2
			&& char.IsLetter( configured[0] )
			&& configured[1] == ':' )
		{
			throw new InvalidOperationException(
				"Generated output must use a path relative to the project's Assets folder." );
		}

		var assetsRoot = Path.GetFullPath( contentRoot );
		var full = Path.GetFullPath( Path.Combine(
			assetsRoot,
			configured.Replace( '/', Path.DirectorySeparatorChar ) ) );
		var relative = Path.GetRelativePath( assetsRoot, full );
		if ( Path.IsPathRooted( relative )
			|| relative.Equals( "..", StringComparison.Ordinal )
			|| relative.StartsWith( $"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal )
			|| relative.StartsWith( $"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal ) )
			throw new InvalidOperationException( "Generated output must stay inside the project's Assets folder." );
		return full;
	}

	private static void LoadOwnershipManifest(
		WeaponAnimationDocument document,
		string outputRoot )
	{
		var manifestPath = Path.Combine( outputRoot, ManifestFile );
		if ( !File.Exists( manifestPath ) )
		{
			document.Manifest ??= new GenerationManifest();
			return;
		}

		var manifest = Json.Deserialize<GenerationManifest>(
			File.ReadAllText( manifestPath ) );
		if ( manifest is null )
			throw new InvalidDataException( $"'{manifestPath}' does not contain a valid manifest." );

		document.Manifest = manifest;
	}

	private static string InputHash( WeaponAnimationDocument document )
	{
		var clone = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )
			?? throw new InvalidOperationException( "Could not clone the weapon animation document." );
		clone.Manifest = new GenerationManifest();
		clone.Workspace = new WorkspaceState();
		return HashText( Json.Serialize( clone ) );
	}

	private static GenerationManifest BuildAndWriteManifest(
		WeaponAnimationDocument document,
		string outputRoot,
		IEnumerable<string> generatedSourcePaths,
		IReadOnlyDictionary<string, string> textFiles,
		List<GenerationDiagnostic> diagnostics,
		Dictionary<string, byte[]> backups,
		HashSet<string> newFiles,
		CancellationToken cancellationToken )
	{
		cancellationToken.ThrowIfCancellationRequested();
		var inputHash = InputHash( document );
		var generatedUtc = document.Manifest.InputHash == inputHash
			? document.Manifest.GeneratedUtc
			: DateTime.UtcNow;
		if ( generatedUtc == default )
			generatedUtc = DateTime.UtcNow;

		var records = new List<GeneratedFileRecord>();
		foreach ( var path in generatedSourcePaths.OrderBy( path => path ) )
		{
			cancellationToken.ThrowIfCancellationRequested();
			records.Add( new GeneratedFileRecord
			{
				RelativePath = path.Replace( '\\', '/' ),
				Sha256 = textFiles.TryGetValue( path, out var text )
					? HashText( text )
					: WeaponSourceImporter.HashFile(
						Path.Combine( outputRoot, path ) ),
				Kind = Path.GetExtension( path ).TrimStart( '.' )
			} );
		}

		var manifest = new GenerationManifest
		{
			GeneratorVersion = GeneratorVersion,
			GeneratedUtc = generatedUtc,
			InputHash = inputHash,
			Diagnostics = diagnostics,
			Files = records
		};
		var manifestPath = Path.Combine( outputRoot, ManifestFile );
		if ( File.Exists( manifestPath ) )
			backups.TryAdd( manifestPath, File.ReadAllBytes( manifestPath ) );
		else
			newFiles.Add( manifestPath );
		AtomicFile.WriteAllText( manifestPath, Json.Serialize( manifest ) );
		return manifest;
	}

	private static WeaponAnimationDocument CreateGenerationSnapshot(
		WeaponAnimationDocument document )
	{
		var snapshot = Json.Deserialize<WeaponAnimationDocument>(
			Json.Serialize( document ) )
			?? throw new InvalidOperationException(
				"Could not clone the weapon animation document." );
		snapshot.Manifest = Json.Deserialize<GenerationManifest>(
			Json.Serialize( document.Manifest ?? new GenerationManifest() ) )
			?? new GenerationManifest();
		return snapshot;
	}

	private static string HashText( string value ) =>
		Convert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( value ) ) )
			.ToLowerInvariant();

	private static void RestoreGeneratedFiles(
		IEnumerable<string> newFiles,
		IReadOnlyDictionary<string, byte[]> backups )
	{
		DeleteGeneratedFiles( newFiles );
		foreach ( var backup in backups )
		{
			var directory = Path.GetDirectoryName( backup.Key );
			if ( !string.IsNullOrWhiteSpace( directory ) )
				Directory.CreateDirectory( directory );
			File.WriteAllBytes( backup.Key, backup.Value );
		}

		// Consumer compiled files were deliberately removed before rewriting. Re-register their
		// restored sources so cancellation leaves the previous viewmodel usable after recompilation.
		foreach ( var source in OrderForWrite(
			backups.Keys.Where( IsCompiledConsumer ) ) )
		{
			try
			{
				var asset = AssetSystem.RegisterFile( source )
					?? AssetSystem.FindByPath( source );
				asset?.Compile( true );
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] could not requeue restored asset '{source}': {ex.Message}" );
			}
		}
	}

	private static GenerationResult CancelledResult(
		ValidationReport validation,
		string outputFolder = "" ) => new()
	{
		Success = false,
		Cancelled = true,
		OutputFolder = outputFolder,
		Validation = validation,
		Diagnostics =
		[
			Diagnostic(
				ValidationSeverity.Warning,
				"generation.cancelled",
				"Generation was cancelled before any output files were changed." )
		]
	};

	private static GenerationResult Failed(
		ValidationReport validation,
		string code,
		string message ) => new()
	{
		Success = false,
		Validation = validation,
		Diagnostics = [Diagnostic( ValidationSeverity.Error, code, message )]
	};

	private static GenerationDiagnostic Diagnostic(
		ValidationSeverity severity,
		string code,
		string message,
		string assetPath = "" ) => new()
	{
		Severity = severity,
		Code = code,
		Message = message,
		AssetPath = assetPath
	};
}
sonac.sbox-animator / Editor/Services/DmxWriter.cs
Editor library
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public static class DmxWriter
{
	private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
	private const string CarrierMaterial = "materials/tools/toolsinvisible.vmat";
	private static readonly Vector3 BoneVisibilitySinkOffset = new( 0, 0, -8192 );

	public static string WriteAnimation(
		WeaponAnimationDocument document,
		HostSkeleton skeleton,
		WeaponAnimationClip clip,
		CancellationToken cancellationToken = default )
	{
		cancellationToken.ThrowIfCancellationRequested();
		if ( skeleton.Bones.Count == 0 )
			throw new InvalidOperationException( "The animation host skeleton contains no bones." );

		var sampleRate = MathF.Max( clip.SampleRate, 1.0f );
		var frameCount = Math.Max( 1, (int)MathF.Round( clip.Duration * sampleRate ) );
		var compilerBindLocal = skeleton.BuildCompilerBindLocalTransforms();
		var times = new string[frameCount + 1];
		var poses = new IReadOnlyDictionary<string, Transform>[frameCount + 1];
		for ( var frame = 0; frame <= frameCount; frame++ )
		{
			cancellationToken.ThrowIfCancellationRequested();
			if ( (frame & 7) == 7 )
				Thread.Yield();
			var time = MathF.Min( frame / sampleRate, clip.Duration );
			times[frame] = F( time );
			var evaluated = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, time );
			ApplyBoneVisibility( document, skeleton, clip, time, evaluated );
			poses[frame] = BuildCompilerPoseLocals( skeleton, evaluated.Local );
		}

		var prefix = $"animation:{clip.Id}";
		var rootId = Id( $"{prefix}:root" );
		var modelId = Id( $"{prefix}:model" );
		var modelTransformId = Id( $"{prefix}:model-transform" );
		var baseStateId = Id( $"{prefix}:base-state" );
		var baseModelTransformId = Id( $"{prefix}:base-model-transform" );
		var animationListId = Id( $"{prefix}:animation-list" );
		var clipId = Id( $"{prefix}:clip" );
		var timeFrameId = Id( $"{prefix}:time-frame" );
		var builder = new StringBuilder();

		builder.AppendLine( "<!-- dmx encoding keyvalues2 4 format model 22 -->" );
		builder.AppendLine( "\"DmElement\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", rootId );
		Attribute( builder, 1, "name", "string", "root" );
		Attribute( builder, 1, "skeleton", "element", modelId );
		Attribute( builder, 1, "animationList", "element", animationListId );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeModel\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", modelId );
		Attribute( builder, 1, "name", "string", "weapon_animation_host" );
		Attribute( builder, 1, "transform", "element", modelTransformId );
		Attribute( builder, 1, "shape", "element", "" );
		Attribute( builder, 1, "visible", "bool", "1" );
		ElementArray(
			builder,
			1,
			"children",
			skeleton.Bones
				.Where( bone => string.IsNullOrWhiteSpace( bone.ParentName )
					|| !skeleton.ByName.ContainsKey( bone.ParentName ) )
				.Select( bone => AnimationJointId( prefix, bone.Index ) ) );
		ElementArray(
			builder,
			1,
			"jointList",
			new[] { modelId }.Concat(
				skeleton.Bones.Select( bone => AnimationJointId( prefix, bone.Index ) ) ) );
		ElementArray( builder, 1, "baseStates", [baseStateId] );
		Attribute( builder, 1, "upAxis", "string", "Z" );
		builder.AppendLine( "\t\"axisSystem\" \"DmeAxisSystem\"" );
		builder.AppendLine( "\t{" );
		Attribute( builder, 2, "id", "elementid", Id( $"{prefix}:axis-system" ) );
		Attribute( builder, 2, "name", "string", "" );
		Attribute( builder, 2, "upAxis", "int", "3" );
		Attribute( builder, 2, "forwardParity", "int", "1" );
		Attribute( builder, 2, "coordSys", "int", "0" );
		builder.AppendLine( "\t}" );
		builder.AppendLine( "}" );
		builder.AppendLine();

		foreach ( var bone in skeleton.Bones )
			WriteAnimationJoint( builder, skeleton, bone, prefix );

		ExternalTransformElement( builder, modelTransformId, "model", Transform.Zero );
		builder.AppendLine();

		foreach ( var bone in skeleton.Bones )
		{
			ExternalTransformElement(
				builder,
				AnimationTransformId( prefix, bone.Index ),
				bone.Name,
				compilerBindLocal[bone.Name] );
			builder.AppendLine();
		}

		ExternalTransformElement( builder, baseModelTransformId, "model", Transform.Zero );
		builder.AppendLine();
		foreach ( var bone in skeleton.Bones )
		{
			ExternalTransformElement(
				builder,
				AnimationBaseTransformId( prefix, bone.Index ),
				bone.Name,
				compilerBindLocal[bone.Name] );
			builder.AppendLine();
		}

		builder.AppendLine( "\"DmeTransformList\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", baseStateId );
		Attribute( builder, 1, "name", "string", "base" );
		ElementArray(
			builder,
			1,
			"transforms",
			new[] { baseModelTransformId }.Concat(
				skeleton.Bones.Select( bone =>
					AnimationBaseTransformId( prefix, bone.Index ) ) ) );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeAnimationList\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", animationListId );
		Attribute( builder, 1, "name", "string", clip.Name );
		ElementArray( builder, 1, "animations", [clipId] );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeChannelsClip\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", clipId );
		Attribute( builder, 1, "name", "string", WeaponAnimationNames.SequenceName( clip ) );
		Attribute( builder, 1, "timeFrame", "element", timeFrameId );
		Attribute( builder, 1, "color", "color", "0 0 0 0" );
		Attribute( builder, 1, "text", "string", "" );
		Attribute( builder, 1, "mute", "bool", "0" );
		ElementArray( builder, 1, "trackGroups", [] );
		Attribute( builder, 1, "displayScale", "float", "1" );
		ElementArray(
			builder,
			1,
			"channels",
			skeleton.Bones.SelectMany( bone => new[]
			{
				AnimationChannelId( prefix, bone.Index, "position" ),
				AnimationChannelId( prefix, bone.Index, "orientation" ),
				AnimationChannelId( prefix, bone.Index, "scale" )
			} ) );
		Attribute( builder, 1, "frameRate", "float", F( sampleRate ) );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeTimeFrame\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", timeFrameId );
		Attribute( builder, 1, "name", "string", "timeFrame" );
		Attribute( builder, 1, "start", "time", "0" );
		Attribute( builder, 1, "duration", "time", F( clip.Duration ) );
		Attribute( builder, 1, "offset", "time", "0" );
		Attribute( builder, 1, "scale", "float", "1" );
		builder.AppendLine( "}" );
		builder.AppendLine();

		foreach ( var bone in skeleton.Bones )
		{
			cancellationToken.ThrowIfCancellationRequested();
			var values = poses
				.Select( pose => pose[bone.Name] )
				.ToArray();
			WriteVectorChannel(
				builder,
				prefix,
				bone,
				"position",
				times,
				values.Select( value => Vector( value.Position ) ).ToArray() );
			WriteQuaternionChannel(
				builder,
				prefix,
				bone,
				times,
				values.Select( value => Quaternion( value.Rotation.Normal ) ).ToArray() );
			WriteFloatChannel(
				builder,
				prefix,
				bone,
				times,
				values.Select( value => F( value.Scale.x ) ).ToArray() );
		}

		return builder.ToString();
	}

	internal static IReadOnlyDictionary<string, Transform> BuildCompilerPoseLocals(
		HostSkeleton skeleton,
		IReadOnlyDictionary<string, Transform> authoredLocal )
	{
		var authoredModel = BuildModelTransforms( skeleton, authoredLocal );
		var exportLocal = new Dictionary<string, Transform>( StringComparer.OrdinalIgnoreCase );
		var exportModel = new Dictionary<string, Transform>( StringComparer.OrdinalIgnoreCase );
		var pending = skeleton.Bones.ToList();
		while ( pending.Count > 0 )
		{
			var progressed = false;
			for ( var i = pending.Count - 1; i >= 0; i-- )
			{
				var bone = pending[i];
				if ( !authoredModel.TryGetValue( bone.Name, out var desiredModel ) )
				{
					pending.RemoveAt( i );
					progressed = true;
					continue;
				}
				if ( !string.IsNullOrWhiteSpace( bone.ParentName )
					&& skeleton.ByName.ContainsKey( bone.ParentName )
					&& !exportModel.ContainsKey( bone.ParentName ) )
				{
					continue;
				}

				var authored = authoredLocal[bone.Name];
				var bind = skeleton.GetBindLocal( bone );
				var relativeScale = new Vector3(
					ScaleRatio( authored.Scale.x, bind.Scale.x ),
					ScaleRatio( authored.Scale.y, bind.Scale.y ),
					ScaleRatio( authored.Scale.z, bind.Scale.z ) );
				Transform local;
				if ( string.IsNullOrWhiteSpace( bone.ParentName )
					|| !exportModel.TryGetValue( bone.ParentName, out var parent ) )
				{
					local = new Transform(
						desiredModel.Position,
						desiredModel.Rotation.Normal,
						relativeScale );
				}
				else
				{
					local = new Transform(
						parent.PointToLocal( desiredModel.Position ),
						(parent.Rotation.Inverse * desiredModel.Rotation).Normal,
						relativeScale );
				}

				exportLocal[bone.Name] = local;
				exportModel[bone.Name] = string.IsNullOrWhiteSpace( bone.ParentName )
					|| !exportModel.TryGetValue( bone.ParentName, out var exportParent )
						? local
						: ComposeLocal( exportParent, local );
				pending.RemoveAt( i );
				progressed = true;
			}

			if ( progressed )
				continue;

			throw new InvalidOperationException(
				$"The animation host contains a cyclic pose hierarchy near '{pending[0].Name}'." );
		}

		return exportLocal;
	}

	private static IReadOnlyDictionary<string, Transform> BuildModelTransforms(
		HostSkeleton skeleton,
		IReadOnlyDictionary<string, Transform> local )
	{
		var model = new Dictionary<string, Transform>( StringComparer.OrdinalIgnoreCase );
		var pending = skeleton.Bones.ToList();
		while ( pending.Count > 0 )
		{
			var progressed = false;
			for ( var i = pending.Count - 1; i >= 0; i-- )
			{
				var bone = pending[i];
				if ( !local.TryGetValue( bone.Name, out var boneLocal ) )
				{
					pending.RemoveAt( i );
					progressed = true;
					continue;
				}
				if ( !string.IsNullOrWhiteSpace( bone.ParentName )
					&& skeleton.ByName.ContainsKey( bone.ParentName )
					&& !model.ContainsKey( bone.ParentName ) )
				{
					continue;
				}

				model[bone.Name] = string.IsNullOrWhiteSpace( bone.ParentName )
					|| !model.TryGetValue( bone.ParentName, out var parent )
						? boneLocal
						: ComposeLocal( parent, boneLocal );
				pending.RemoveAt( i );
				progressed = true;
			}

			if ( progressed )
				continue;

			throw new InvalidOperationException(
				$"The animation host contains a cyclic pose hierarchy near '{pending[0].Name}'." );
		}

		return model;
	}

	private static Transform ComposeLocal( Transform parent, Transform local ) => new(
		parent.PointToWorld( local.Position ),
		parent.Rotation * local.Rotation,
		parent.Scale * local.Scale );

	private static float ScaleRatio( float value, float bindValue )
	{
		if ( !float.IsFinite( value ) )
			return 1.0f;

		return float.IsFinite( bindValue ) && MathF.Abs( bindValue ) > 0.000001f
			? value / bindValue
			: value;
	}

	private static void ApplyBoneVisibility(
		WeaponAnimationDocument document,
		HostSkeleton skeleton,
		WeaponAnimationClip clip,
		float time,
		EvaluatedPose pose )
	{
		foreach ( var part in document.Rig.VisibilityParts.Where( x =>
			x.RenderMode == VisibilityRenderMode.BoneBranch
			&& !WeaponVisibilityEvaluator.Evaluate( x, clip, time ) ) )
		{
			var definition = document.Rig.FindBone( part.BoneId )
				?? document.Rig.FindBone( part.BoneName );
			var boneName = definition is not null
				&& definition.Id.Equals(
					document.Rig.SourceSkeletonRootId,
					StringComparison.OrdinalIgnoreCase )
					? "weapon_root"
					: definition?.Name ?? part.BoneName;
			if ( !skeleton.ByName.ContainsKey( boneName )
				|| !pose.Local.TryGetValue( boneName, out var local ) )
				continue;

			// Some ModelDoc paths normalize animated bone scale. The off-screen translation keeps
			// visibility native and deterministic even when the scale channel is discarded.
			pose.Local[boneName] = local
				.WithPosition( local.Position + BoneVisibilitySinkOffset )
				.WithScale( local.Scale * 0.0001f );
		}
	}

	public static string WriteReference( HostSkeleton skeleton )
	{
		if ( skeleton.Bones.Count == 0 )
			throw new InvalidOperationException( "The animation host skeleton contains no bones." );

		var rootId = Id( "root" );
		var modelId = Id( "model" );
		var modelTransformId = Id( "model-transform" );
		var meshDagId = Id( "mesh-dag" );
		var meshTransformId = Id( "mesh-transform" );
		var meshId = Id( "mesh" );
		var vertexDataId = Id( "vertex-data" );
		var faceSetId = Id( "face-set" );
		var materialId = Id( "material" );
		var compilerBindLocal = skeleton.BuildCompilerBindLocalTransforms();
		var builder = new StringBuilder();

		builder.AppendLine( "<!-- dmx encoding keyvalues2 4 format model 22 -->" );
		builder.AppendLine( "\"DmElement\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", rootId );
		Attribute( builder, 1, "name", "string", "root" );
		Attribute( builder, 1, "model", "element", modelId );
		Attribute( builder, 1, "skeleton", "element", modelId );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeModel\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", modelId );
		Attribute( builder, 1, "name", "string", "weapon_animation_host" );
		TransformElement( builder, 1, modelTransformId, "model", Transform.Zero );
		Attribute( builder, 1, "visible", "bool", "1" );
		ElementArray(
			builder,
			1,
			"children",
			skeleton.Bones
				.Where( bone => string.IsNullOrWhiteSpace( bone.ParentName )
					|| !skeleton.ByName.ContainsKey( bone.ParentName ) )
				.Select( bone => JointId( bone.Index ) )
				.Append( meshDagId ) );
		ElementArray( builder, 1, "jointList", skeleton.Bones.Select( bone => JointId( bone.Index ) ) );
		Attribute( builder, 1, "upAxis", "string", "Z" );
		builder.AppendLine( "\t\"axisSystem\" \"DmeAxisSystem\"" );
		builder.AppendLine( "\t{" );
		Attribute( builder, 2, "id", "elementid", Id( "axis-system" ) );
		Attribute( builder, 2, "name", "string", "" );
		Attribute( builder, 2, "upAxis", "int", "3" );
		Attribute( builder, 2, "forwardParity", "int", "1" );
		Attribute( builder, 2, "coordSys", "int", "0" );
		builder.AppendLine( "\t}" );
		builder.AppendLine( "}" );
		builder.AppendLine();

		foreach ( var bone in skeleton.Bones )
			WriteJoint( builder, skeleton, bone, compilerBindLocal[bone.Name] );

		builder.AppendLine( "\"DmeDag\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", meshDagId );
		Attribute( builder, 1, "name", "string", "host_reference_triangle" );
		TransformElement( builder, 1, meshTransformId, "host_reference_triangle", Transform.Zero );
		Attribute( builder, 1, "shape", "element", meshId );
		Attribute( builder, 1, "visible", "bool", "1" );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeMesh\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", meshId );
		Attribute( builder, 1, "name", "string", "host_reference_triangle" );
		Attribute( builder, 1, "visible", "bool", "1" );
		Attribute( builder, 1, "currentState", "element", vertexDataId );
		ElementArray( builder, 1, "baseStates", [vertexDataId] );
		builder.AppendLine( "\t\"faceSets\" \"element_array\"" );
		builder.AppendLine( "\t[" );
		builder.AppendLine( "\t\t\"DmeFaceSet\"" );
		builder.AppendLine( "\t\t{" );
		Attribute( builder, 3, "id", "elementid", faceSetId );
		Attribute( builder, 3, "name", "string", CarrierMaterial );
		IntArray( builder, 3, "faces", CarrierFaces( skeleton ) );
		builder.AppendLine( "\t\t\t\"material\" \"DmeMaterial\"" );
		builder.AppendLine( "\t\t\t{" );
		Attribute( builder, 4, "id", "elementid", materialId );
		Attribute( builder, 4, "name", "string", CarrierMaterial );
		Attribute( builder, 4, "mtlName", "string", CarrierMaterial );
		builder.AppendLine( "\t\t\t}" );
		builder.AppendLine( "\t\t}" );
		builder.AppendLine( "\t]" );
		builder.AppendLine( "}" );
		builder.AppendLine();

		WriteVertexData( builder, vertexDataId, skeleton );
		return builder.ToString();
	}

	private static void WriteAnimationJoint(
		StringBuilder builder,
		HostSkeleton skeleton,
		HostBone bone,
		string prefix )
	{
		builder.AppendLine( "\"DmeJoint\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", AnimationJointId( prefix, bone.Index ) );
		Attribute( builder, 1, "name", "string", bone.Name );
		Attribute(
			builder,
			1,
			"transform",
			"element",
			AnimationTransformId( prefix, bone.Index ) );
		Attribute( builder, 1, "shape", "element", "" );
		Attribute( builder, 1, "visible", "bool", "1" );
		ElementArray(
			builder,
			1,
			"children",
			skeleton.Bones
				.Where( child => child.ParentName.Equals(
					bone.Name,
					StringComparison.OrdinalIgnoreCase ) )
				.Select( child => AnimationJointId( prefix, child.Index ) ) );
		builder.AppendLine( "}" );
		builder.AppendLine();
	}

	private static void WriteVectorChannel(
		StringBuilder builder,
		string prefix,
		HostBone bone,
		string attribute,
		string[] times,
		string[] values )
	{
		var channelId = AnimationChannelId( prefix, bone.Index, attribute );
		var logId = Id( $"{prefix}:log:{bone.Index}:{attribute}" );
		var layerId = Id( $"{prefix}:layer:{bone.Index}:{attribute}" );
		var transformId = AnimationTransformId( prefix, bone.Index );

		WriteChannelHeader(
			builder,
			channelId,
			$"{bone.Name}_p",
			transformId,
			"position",
			logId );
		builder.AppendLine( "\"DmeVector3Log\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", logId );
		Attribute( builder, 1, "name", "string", "vector3 log" );
		ElementArray( builder, 1, "layers", [layerId] );
		Attribute( builder, 1, "curveinfo", "element", "" );
		Attribute( builder, 1, "usedefaultvalue", "bool", "0" );
		Attribute( builder, 1, "defaultvalue", "vector3", values[0] );
		TimeArray( builder, 1, "bookmarksX", [] );
		TimeArray( builder, 1, "bookmarksY", [] );
		TimeArray( builder, 1, "bookmarksZ", [] );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeVector3LogLayer\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", layerId );
		Attribute( builder, 1, "name", "string", "vector3 log" );
		TimeArray( builder, 1, "times", times );
		IntArray( builder, 1, "curvetypes", [] );
		VectorArray( builder, 1, "values", "vector3_array", values );
		Attribute( builder, 1, "compressed", "binary", "" );
		builder.AppendLine( "}" );
		builder.AppendLine();
	}

	private static void WriteQuaternionChannel(
		StringBuilder builder,
		string prefix,
		HostBone bone,
		string[] times,
		string[] values )
	{
		var attribute = "orientation";
		var channelId = AnimationChannelId( prefix, bone.Index, attribute );
		var logId = Id( $"{prefix}:log:{bone.Index}:{attribute}" );
		var layerId = Id( $"{prefix}:layer:{bone.Index}:{attribute}" );
		var transformId = AnimationTransformId( prefix, bone.Index );

		WriteChannelHeader(
			builder,
			channelId,
			$"{bone.Name}_o",
			transformId,
			attribute,
			logId );
		builder.AppendLine( "\"DmeQuaternionLog\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", logId );
		Attribute( builder, 1, "name", "string", "quaternion log" );
		ElementArray( builder, 1, "layers", [layerId] );
		Attribute( builder, 1, "curveinfo", "element", "" );
		Attribute( builder, 1, "usedefaultvalue", "bool", "0" );
		Attribute( builder, 1, "defaultvalue", "quaternion", values[0] );
		TimeArray( builder, 1, "bookmarksX", [] );
		TimeArray( builder, 1, "bookmarksY", [] );
		TimeArray( builder, 1, "bookmarksZ", [] );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeQuaternionLogLayer\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", layerId );
		Attribute( builder, 1, "name", "string", "quaternion log" );
		TimeArray( builder, 1, "times", times );
		IntArray( builder, 1, "curvetypes", [] );
		VectorArray( builder, 1, "values", "quaternion_array", values );
		Attribute( builder, 1, "compressed", "binary", "" );
		builder.AppendLine( "}" );
		builder.AppendLine();
	}

	private static void WriteFloatChannel(
		StringBuilder builder,
		string prefix,
		HostBone bone,
		string[] times,
		string[] values )
	{
		const string attribute = "scale";
		var channelId = AnimationChannelId( prefix, bone.Index, attribute );
		var logId = Id( $"{prefix}:log:{bone.Index}:{attribute}" );
		var layerId = Id( $"{prefix}:layer:{bone.Index}:{attribute}" );
		var transformId = AnimationTransformId( prefix, bone.Index );

		WriteChannelHeader(
			builder,
			channelId,
			$"{bone.Name}_s",
			transformId,
			attribute,
			logId );
		builder.AppendLine( "\"DmeFloatLog\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", logId );
		Attribute( builder, 1, "name", "string", "float log" );
		ElementArray( builder, 1, "layers", [layerId] );
		Attribute( builder, 1, "curveinfo", "element", "" );
		Attribute( builder, 1, "usedefaultvalue", "bool", "0" );
		Attribute( builder, 1, "defaultvalue", "float", values[0] );
		TimeArray( builder, 1, "bookmarks", [] );
		builder.AppendLine( "}" );
		builder.AppendLine();

		builder.AppendLine( "\"DmeFloatLogLayer\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", layerId );
		Attribute( builder, 1, "name", "string", "float log" );
		TimeArray( builder, 1, "times", times );
		IntArray( builder, 1, "curvetypes", [] );
		VectorArray( builder, 1, "values", "float_array", values );
		Attribute( builder, 1, "compressed", "binary", "" );
		builder.AppendLine( "}" );
		builder.AppendLine();
	}

	private static void WriteChannelHeader(
		StringBuilder builder,
		string channelId,
		string name,
		string transformId,
		string attribute,
		string logId )
	{
		builder.AppendLine( "\"DmeChannel\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", channelId );
		Attribute( builder, 1, "name", "string", name );
		Attribute( builder, 1, "fromElement", "element", "" );
		Attribute(
			builder,
			1,
			"fromAttribute",
			"string",
			attribute switch
			{
				"position" => "valuePosition",
				"orientation" => "valueOrientation",
				_ => "value"
			} );
		Attribute( builder, 1, "fromIndex", "int", "0" );
		Attribute( builder, 1, "toElement", "element", transformId );
		Attribute( builder, 1, "toAttribute", "string", attribute );
		Attribute( builder, 1, "toIndex", "int", "0" );
		Attribute( builder, 1, "mode", "int", "1" );
		Attribute( builder, 1, "log", "element", logId );
		builder.AppendLine( "}" );
		builder.AppendLine();
	}

	private static void WriteJoint(
		StringBuilder builder,
		HostSkeleton skeleton,
		HostBone bone,
		Transform bindLocal )
	{
		builder.AppendLine( "\"DmeJoint\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", JointId( bone.Index ) );
		Attribute( builder, 1, "name", "string", bone.Name );
		TransformElement(
			builder,
			1,
			Id( $"joint-transform:{bone.Index}" ),
			bone.Name,
			bindLocal );
		Attribute( builder, 1, "visible", "bool", "1" );
		ElementArray(
			builder,
			1,
			"children",
			skeleton.Bones
				.Where( child => child.ParentName.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) )
				.Select( child => JointId( child.Index ) ) );
		builder.AppendLine( "}" );
		builder.AppendLine();
	}

	private static void WriteVertexData(
		StringBuilder builder,
		string vertexDataId,
		HostSkeleton skeleton )
	{
		var positions = new string[skeleton.Bones.Count * 3];
		var normals = new string[positions.Length];
		var texcoords = new string[positions.Length];
		var indices = new int[positions.Length];
		var weights = new float[positions.Length];
		var blendIndices = new int[positions.Length];

		for ( var boneIndex = 0; boneIndex < skeleton.Bones.Count; boneIndex++ )
		{
			var vertex = boneIndex * 3;

			// A tiny weighted triangle keeps each host bone from being culled by ModelDoc.
			positions[vertex] = "0 0 0";
			positions[vertex + 1] = "0.001 0 0";
			positions[vertex + 2] = "0 0.001 0";
			normals[vertex] = normals[vertex + 1] = normals[vertex + 2] = "0 0 1";
			texcoords[vertex] = "0 0";
			texcoords[vertex + 1] = "1 0";
			texcoords[vertex + 2] = "0 1";
			indices[vertex] = vertex;
			indices[vertex + 1] = vertex + 1;
			indices[vertex + 2] = vertex + 2;
			weights[vertex] = weights[vertex + 1] = weights[vertex + 2] = 1.0f;
			blendIndices[vertex] = blendIndices[vertex + 1] = blendIndices[vertex + 2] = boneIndex;
		}

		builder.AppendLine( "\"DmeVertexData\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", vertexDataId );
		Attribute( builder, 1, "name", "string", "bind" );
		StringArray(
			builder,
			1,
			"vertexFormat",
			["position$0", "normal$0", "texcoord$0", "blendweights$0", "blendindices$0"] );
		Attribute( builder, 1, "jointCount", "int", "1" );
		Attribute( builder, 1, "flipVCoordinates", "bool", "1" );
		VectorArray( builder, 1, "position$0", "vector3_array", positions );
		IntArray( builder, 1, "position$0Indices", indices );
		VectorArray( builder, 1, "normal$0", "vector3_array", normals );
		IntArray( builder, 1, "normal$0Indices", indices );
		VectorArray( builder, 1, "texcoord$0", "vector2_array", texcoords );
		IntArray( builder, 1, "texcoord$0Indices", indices );
		FloatArray( builder, 1, "blendweights$0", weights );
		IntArray( builder, 1, "blendindices$0", blendIndices );
		builder.AppendLine( "}" );
	}

	private static int[] CarrierFaces( HostSkeleton skeleton )
	{
		var faces = new int[skeleton.Bones.Count * 4];
		for ( var boneIndex = 0; boneIndex < skeleton.Bones.Count; boneIndex++ )
		{
			var vertex = boneIndex * 3;
			var face = boneIndex * 4;
			faces[face] = vertex;
			faces[face + 1] = vertex + 1;
			faces[face + 2] = vertex + 2;
			faces[face + 3] = -1;
		}

		return faces;
	}

	private static void TransformElement(
		StringBuilder builder,
		int indent,
		string id,
		string name,
		Transform transform )
	{
		var tabs = new string( '\t', indent );
		builder.Append( tabs ).AppendLine( "\"transform\" \"DmeTransform\"" );
		builder.Append( tabs ).AppendLine( "{" );
		Attribute( builder, indent + 1, "id", "elementid", id );
		Attribute( builder, indent + 1, "name", "string", name );
		Attribute(
			builder,
			indent + 1,
			"position",
			"vector3",
			$"{F( transform.Position.x )} {F( transform.Position.y )} {F( transform.Position.z )}" );
		Attribute(
			builder,
			indent + 1,
			"orientation",
			"quaternion",
			$"{F( transform.Rotation.x )} {F( transform.Rotation.y )} "
			+ $"{F( transform.Rotation.z )} {F( transform.Rotation.w )}" );
		Attribute( builder, indent + 1, "scale", "float", F( transform.Scale.x ) );
		builder.Append( tabs ).AppendLine( "}" );
	}

	private static void ExternalTransformElement(
		StringBuilder builder,
		string id,
		string name,
		Transform transform )
	{
		builder.AppendLine( "\"DmeTransform\"" );
		builder.AppendLine( "{" );
		Attribute( builder, 1, "id", "elementid", id );
		Attribute( builder, 1, "name", "string", name );
		Attribute( builder, 1, "position", "vector3", Vector( transform.Position ) );
		Attribute( builder, 1, "orientation", "quaternion", Quaternion( transform.Rotation.Normal ) );
		Attribute( builder, 1, "scale", "float", F( transform.Scale.x ) );
		builder.AppendLine( "}" );
	}

	private static void ElementArray(
		StringBuilder builder,
		int indent,
		string name,
		System.Collections.Generic.IEnumerable<string> values )
	{
		var tabs = new string( '\t', indent );
		var items = values.ToArray();
		builder.Append( tabs ).Append( '"' ).Append( name ).AppendLine( "\" \"element_array\"" );
		builder.Append( tabs ).AppendLine( "[" );
		for ( var i = 0; i < items.Length; i++ )
		{
			builder.Append( '\t', indent + 1 )
				.Append( "\"element\" \"" )
				.Append( Escape( items[i] ) )
				.Append( '"' );
			if ( i < items.Length - 1 )
				builder.Append( ',' );
			builder.AppendLine();
		}
		builder.Append( tabs ).AppendLine( "]" );
	}

	private static void StringArray( StringBuilder builder, int indent, string name, string[] values )
	{
		var tabs = new string( '\t', indent );
		builder.Append( tabs ).Append( '"' ).Append( name ).AppendLine( "\" \"string_array\"" );
		builder.Append( tabs ).AppendLine( "[" );
		for ( var i = 0; i < values.Length; i++ )
		{
			builder.Append( '\t', indent + 1 ).Append( '"' ).Append( Escape( values[i] ) ).Append( '"' );
			if ( i < values.Length - 1 )
				builder.Append( ',' );
			builder.AppendLine();
		}
		builder.Append( tabs ).AppendLine( "]" );
	}

	private static void IntArray( StringBuilder builder, int indent, string name, int[] values ) =>
		VectorArray( builder, indent, name, "int_array", values.Select( x => x.ToString( Invariant ) ).ToArray() );

	private static void FloatArray( StringBuilder builder, int indent, string name, float[] values ) =>
		VectorArray( builder, indent, name, "float_array", values.Select( F ).ToArray() );

	private static void TimeArray( StringBuilder builder, int indent, string name, string[] values ) =>
		VectorArray( builder, indent, name, "time_array", values );

	private static void VectorArray(
		StringBuilder builder,
		int indent,
		string name,
		string type,
		string[] values )
	{
		var tabs = new string( '\t', indent );
		builder.Append( tabs ).Append( '"' ).Append( name ).Append( "\" \"" ).Append( type ).AppendLine( "\"" );
		builder.Append( tabs ).AppendLine( "[" );
		for ( var i = 0; i < values.Length; i++ )
		{
			builder.Append( '\t', indent + 1 ).Append( '"' ).Append( values[i] ).Append( '"' );
			if ( i < values.Length - 1 )
				builder.Append( ',' );
			builder.AppendLine();
		}
		builder.Append( tabs ).AppendLine( "]" );
	}

	private static void Attribute(
		StringBuilder builder,
		int indent,
		string name,
		string type,
		string value )
	{
		builder.Append( '\t', indent )
			.Append( '"' ).Append( name ).Append( '"' );
		if ( !string.IsNullOrWhiteSpace( type ) )
			builder.Append( " \"" ).Append( type ).Append( '"' );
		builder.Append( " \"" ).Append( Escape( value ) ).AppendLine( "\"" );
	}

	private static string JointId( int index ) => Id( $"joint:{index}" );
	private static string AnimationJointId( string prefix, int index ) =>
		Id( $"{prefix}:joint:{index}" );
	private static string AnimationTransformId( string prefix, int index ) =>
		Id( $"{prefix}:transform:{index}" );
	private static string AnimationBaseTransformId( string prefix, int index ) =>
		Id( $"{prefix}:base-transform:{index}" );
	private static string AnimationChannelId( string prefix, int index, string attribute ) =>
		Id( $"{prefix}:channel:{index}:{attribute}" );

	private static string Id( string key )
	{
		var bytes = SHA256.HashData( Encoding.UTF8.GetBytes( $"SboxWeaponAnimator.DmxReference:{key}" ) );
		return new Guid( bytes.AsSpan( 0, 16 ) ).ToString();
	}

	private static string F( float value ) => value.ToString( "0.######", Invariant );
	private static string Vector( Vector3 value ) =>
		$"{F( value.x )} {F( value.y )} {F( value.z )}";
	private static string Quaternion( Rotation value ) =>
		$"{F( value.x )} {F( value.y )} {F( value.z )} {F( value.w )}";
	private static string Escape( string value ) => value.Replace( "\\", "\\\\" ).Replace( "\"", "\\\"" );
}
sonac.sbox-animator / Editor/Widgets/CalibrationWorkspacePanels.cs
Editor library
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public enum ViewportPickMode
{
	None,
	MeasurementFirst,
	MeasurementSecond,
	GripAnchor,
	RearBoreAnchor,
	FrontBoreAnchor,
	MuzzleAnchor,
	EjectAnchor,
	CustomAnchor
}

public static class CalibrationSelection
{
	private const string AnchorPrefix = "@anchor:";

	public static string Anchor( AnchorKind kind ) => $"{AnchorPrefix}{kind}";

	/// <summary>
	/// Custom anchors carry their id in the token, because a weapon may hold several of them.
	/// </summary>
	public static string Anchor( WeaponAnchor anchor ) =>
		anchor.Kind == AnchorKind.Custom
			? $"{AnchorPrefix}{AnchorKind.Custom}:{anchor.Id:N}"
			: Anchor( anchor.Kind );

	public static string DisplayName( AnchorKind kind ) => kind switch
	{
		AnchorKind.Grip => "Primary grip",
		AnchorKind.RearBore => "Alignment marker — rear",
		AnchorKind.FrontBore => "Alignment marker — front",
		AnchorKind.Muzzle => "Muzzle",
		AnchorKind.Eject => "Eject",
		_ => "Custom anchor"
	};

	public static string DisplayName( WeaponAnchor anchor ) =>
		anchor.Kind == AnchorKind.Custom && !string.IsNullOrWhiteSpace( anchor.Name )
			? anchor.Name
			: DisplayName( anchor.Kind );

	public static bool TryGetAnchor( string control, out AnchorKind kind )
	{
		kind = default;
		if ( !control.StartsWith( AnchorPrefix, StringComparison.Ordinal ) )
			return false;
		var body = control[AnchorPrefix.Length..];
		var separator = body.IndexOf( ':' );
		return Enum.TryParse( separator >= 0 ? body[..separator] : body, out kind );
	}

	public static bool TryGetCustomAnchorId( string control, out Guid id )
	{
		id = Guid.Empty;
		if ( !control.StartsWith( AnchorPrefix, StringComparison.Ordinal ) )
			return false;
		var body = control[AnchorPrefix.Length..];
		var separator = body.IndexOf( ':' );
		return separator >= 0
			&& Guid.TryParseExact( body[(separator + 1)..], "N", out id );
	}

	/// <summary>
	/// Resolves a selection token to its anchor, by id for custom anchors and by kind otherwise.
	/// </summary>
	public static WeaponAnchor? Resolve( WeaponAnimationDocument document, string control ) =>
		TryGetCustomAnchorId( control, out var id )
			? document.Calibration.FindAnchor( id )
			: TryGetAnchor( control, out var kind )
				? document.Calibration.GetAnchor( kind )
				: null;
}

internal sealed class ScrubHandle : Widget
{
	private readonly string _text;
	private readonly Color _accent;
	private readonly float _sensitivity;
	private readonly Func<float> _getValue;
	private readonly Action _begin;
	private readonly Action<float> _preview;
	private readonly Action _end;
	private bool _dragging;
	private float _startX;
	private float _startValue;

	public ScrubHandle(
		string text,
		Color accent,
		float sensitivity,
		Func<float> getValue,
		Action begin,
		Action<float> preview,
		Action end,
		Widget parent ) : base( parent )
	{
		_text = text;
		_accent = accent;
		_sensitivity = sensitivity;
		_getValue = getValue;
		_begin = begin;
		_preview = preview;
		_end = end;
		FixedWidth = 20;
		FixedHeight = 26;
		MouseTracking = true;
		Cursor = CursorShape.SizeH;
		ToolTip = $"Drag {_text} horizontally to adjust";
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton )
		{
			base.OnMousePress( e );
			return;
		}

		_dragging = true;
		_startX = e.ScreenPosition.x;
		_startValue = _getValue();
		_begin();
		e.Accepted = true;
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		if ( !_dragging )
		{
			base.OnMouseMove( e );
			return;
		}

		_preview( _startValue + (e.ScreenPosition.x - _startX) * _sensitivity );
		e.Accepted = true;
	}

	protected override void OnMouseReleased( MouseEvent e )
	{
		if ( !_dragging || e.Button != MouseButtons.Left )
		{
			base.OnMouseReleased( e );
			return;
		}

		_dragging = false;
		_end();
		e.Accepted = true;
	}

	protected override void OnPaint()
	{
		Paint.Antialiasing = true;
		Paint.ClearPen();
		Paint.SetBrush( _accent.WithAlpha( Paint.HasPressed ? 0.42f : Paint.HasMouseOver ? 0.32f : 0.22f ) );
		Paint.DrawRect( LocalRect, 3 );
		Paint.SetPen( _accent.Lighten( 0.25f ) );
		Paint.SetDefaultFont( 10, 650 );
		Paint.DrawText( LocalRect, _text, TextFlag.Center );
	}
}

public sealed class RigAuditPanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly ScrollArea _boneScroll;
	private readonly Widget _boneCanvas;
	private readonly LineEdit _search;
	private readonly Label _sourceStatus;
	private readonly Label _retainedStatus;
	private readonly Dictionary<string, WeaponAnimatorButton> _boneButtons =
		new( StringComparer.OrdinalIgnoreCase );
	private string _lastSelectedBone = "";
	private string _boneStructureSignature = "";
	private string _filter = "";
	private bool _showMovable = true;
	private bool _showStructural = true;
	private bool _showIgnored;

	public event Action? ImportRequested;
	public event Action? RigReviewConfirmed;

	public RigAuditPanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 8 );
		Layout.Spacing = 6;

		var import = WeaponAnimatorTheme.Button(
			"Import rigged model",
			"file_upload",
			() => ImportRequested?.Invoke(),
			this,
			true );
		Layout.Add( import );

		_sourceStatus = WeaponAnimatorTheme.Label( "No source selected", this, true );
		_sourceStatus.WordWrap = true;
		Layout.Add( _sourceStatus );

		_retainedStatus = WeaponAnimatorTheme.Label( "No weapon subtree selected", this, true );
		_retainedStatus.WordWrap = true;
		Layout.Add( _retainedStatus );

		_search = new LineEdit( this )
		{
			PlaceholderText = "Search bones…",
			FixedHeight = 28
		};
		_search.SetStyles( WeaponAnimatorTheme.InputStyle );
		_search.TextEdited += value =>
		{
			_filter = value?.Trim() ?? "";
			RebuildBones();
		};
		Layout.Add( _search );

		var filters = Row( this );
		filters.Layout.Add( Toggle( filters, "Movable", _showMovable, x => _showMovable = x ) );
		filters.Layout.Add( Toggle( filters, "Structural", _showStructural, x => _showStructural = x ) );
		filters.Layout.Add( Toggle( filters, "Ignored", _showIgnored, x => _showIgnored = x ) );
		Layout.Add( filters );

		var rootActions = Row( this );
		rootActions.Layout.Add( WeaponAnimatorTheme.Button(
			"Set selected as weapon root",
			"account_tree",
			SetSelectedWeaponRoot,
			rootActions ), 1 );
		Layout.Add( rootActions );

		var branchActions = Row( this );
		branchActions.Layout.Add( WeaponAnimatorTheme.Button(
			"Include branch",
			"add",
			IncludeSelectedBranch,
			branchActions ) );
		branchActions.Layout.Add( WeaponAnimatorTheme.Button(
			"Exclude branch",
			"remove",
			ExcludeSelectedBranch,
			branchActions ) );
		Layout.Add( branchActions );

		_boneScroll = new ScrollArea( this );
		_boneCanvas = new Widget( _boneScroll );
		_boneCanvas.Layout = Layout.Column();
		_boneCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
		_boneCanvas.Layout.Spacing = 2;
		_boneScroll.Canvas = _boneCanvas;
		Layout.Add( _boneScroll, 1 );

		Layout.Add( WeaponAnimatorTheme.Button(
			"Confirm weapon bones",
			"verified",
			ConfirmWeaponBones,
			this,
			true ) );

		_controller.DocumentChanged += Refresh;
		_controller.SelectionChanged += RefreshBoneSelection;
		Refresh();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= Refresh;
		_controller.SelectionChanged -= RefreshBoneSelection;
		base.OnDestroyed();
	}

	public void Refresh()
	{
		var source = _controller.Document.Source;
		_sourceStatus.Text = string.IsNullOrWhiteSpace( source.SourcePath )
			? "No source selected"
			: $"{source.SourcePath}\n{_controller.Document.Rig.Bones.Count} bones · "
				+ $"{source.Materials.Count( material => material.HasUsableTextures )}/"
				+ $"{source.Materials.Count} textured materials · "
				+ $"{(source.Compiled ? "compiled" : "compile failed")}";
		var rig = _controller.Document.Rig;
		var retained = rig.Bones.Count( WeaponRigHierarchy.IsRetained );
		var structural = rig.Bones.Count( x =>
			x.Inclusion == WeaponBoneInclusion.StructuralBridge
			|| x.Classification == WeaponBoneClassification.Structural );
		var excluded = rig.Bones.Count - retained;
		_retainedStatus.Text = rig.Bones.Count == 0
			? "No weapon subtree selected"
			: $"{retained} retained · {structural} structural · {excluded} excluded"
				+ (rig.ReviewRequired ? "\nReview and confirm the filtered weapon preview." : "\nWeapon bones confirmed.")
				+ (rig.Bones.Any( x =>
						x.Inclusion == WeaponBoneInclusion.Excluded && x.HasSkinInfluence )
					? "\nExcluded branches may affect visible geometry; verify the model before confirming."
					: "");
		var signature = BoneStructureSignature(
			rig,
			_filter,
			_showMovable,
			_showStructural,
			_showIgnored );
		if ( signature != _boneStructureSignature )
			RebuildBones();
		else
			RefreshBoneSelection();
	}

	private void RebuildBones()
	{
		if ( _boneCanvas is null )
			return;
		_boneCanvas.Layout.Clear( true );
		_boneButtons.Clear();
		var bonesByName = _controller.Document.Rig.Bones
			.GroupBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
			.ToDictionary(
				x => x.Key,
				x => x.First(),
				StringComparer.OrdinalIgnoreCase );
		var depths = new Dictionary<string, int>( StringComparer.OrdinalIgnoreCase );

		int ResolveDepth( WeaponBoneDefinition bone, HashSet<string> visiting )
		{
			if ( depths.TryGetValue( bone.Name, out var cached ) )
				return cached;
			if ( !visiting.Add( bone.Name )
				|| string.IsNullOrWhiteSpace( bone.ParentName )
				|| !bonesByName.TryGetValue( bone.ParentName, out var parent ) )
				return depths[bone.Name] = 0;
			var depth = Math.Min( ResolveDepth( parent, visiting ) + 1, 16 );
			visiting.Remove( bone.Name );
			return depths[bone.Name] = depth;
		}

		foreach ( var bone in _controller.Document.Rig.Bones.Where( IsVisible ) )
		{
			var row = Row( _boneCanvas );
			row.FixedHeight = 28;
			var depth = ResolveDepth( bone, [] );
			var name = new WeaponAnimatorButton( $"{new string( ' ', Math.Min( depth, 8 ) * 2 )}{bone.Name}", row )
			{
				Clicked = () => _controller.SelectBone( bone.Name ),
				Tint = WeaponAnimatorTheme.Surface
			};
			_boneButtons[bone.Name] = name;
			row.Layout.Add( name, 1 );

			var classification = new WeaponAnimatorButton( ShortClassification( bone.Classification ), row )
			{
				Clicked = () => CycleClassification( bone ),
				FixedWidth = 34,
				ToolTip = $"{bone.Classification} · {bone.Inclusion}",
				Tint = bone.Classification switch
				{
					WeaponBoneClassification.WeaponRoot => WeaponAnimatorTheme.Coral * 0.55f,
					WeaponBoneClassification.Animatable => WeaponAnimatorTheme.Amber * 0.45f,
					WeaponBoneClassification.Structural => WeaponAnimatorTheme.SurfaceRaised,
					_ => WeaponAnimatorTheme.Background
				}
			};
			row.Layout.Add( classification );
			_boneCanvas.Layout.Add( row );
		}

		_boneCanvas.Layout.AddStretchCell();
		_boneStructureSignature = BoneStructureSignature(
			_controller.Document.Rig,
			_filter,
			_showMovable,
			_showStructural,
			_showIgnored );
		_lastSelectedBone = "";
		RefreshBoneSelection();
	}

	internal static string BoneStructureSignature(
		WeaponRigDefinition rig,
		string filter,
		bool showMovable,
		bool showStructural,
		bool showIgnored )
	{
		var signature = new StringBuilder()
			.Append( filter )
			.Append( '|' )
			.Append( showMovable ? '1' : '0' )
			.Append( showStructural ? '1' : '0' )
			.Append( showIgnored ? '1' : '0' );

		foreach ( var bone in rig.Bones )
		{
			signature
				.Append( '\n' )
				.Append( bone.Id )
				.Append( '\t' )
				.Append( bone.Name )
				.Append( '\t' )
				.Append( bone.ParentId )
				.Append( '\t' )
				.Append( bone.ParentName )
				.Append( '\t' )
				.Append( (int)bone.Classification )
				.Append( '\t' )
				.Append( (int)bone.Inclusion );
		}

		return signature.ToString();
	}

	private void RefreshBoneSelection()
	{
		var selected = _controller.Document.Workspace.SelectedBone;
		if ( _lastSelectedBone.Equals( selected, StringComparison.OrdinalIgnoreCase ) )
			return;
		if ( _boneButtons.TryGetValue( _lastSelectedBone, out var previous ) )
			previous.Tint = WeaponAnimatorTheme.Surface;
		if ( _boneButtons.TryGetValue( selected, out var current ) )
		{
			current.Tint = WeaponAnimatorTheme.Cyan * 0.45f;
			RevealIfNeeded( current );
		}
		_lastSelectedBone = selected;
	}

	/// <summary>
	/// Scrolls a bone selected elsewhere - the viewport, most often - into view, so picking a bone
	/// in the scene does not leave the list parked somewhere else.
	/// </summary>
	private void RevealIfNeeded( Widget button )
	{
		if ( button.Height <= 0 || _boneScroll.Height <= 0 )
			return;

		var viewportTop = _boneScroll.ScreenPosition.y;
		var viewportBottom = viewportTop + _boneScroll.Height;
		var itemTop = button.ScreenPosition.y;
		var itemBottom = itemTop + button.Height;
		if ( itemTop < viewportTop )
			_boneScroll.VerticalScrollbar.Value -= (viewportTop - itemTop).CeilToInt();
		else if ( itemBottom > viewportBottom )
			_boneScroll.VerticalScrollbar.Value += (itemBottom - viewportBottom).CeilToInt();
	}

	private bool IsVisible( WeaponBoneDefinition bone )
	{
		if ( !string.IsNullOrWhiteSpace( _filter )
			&& !bone.Name.Contains( _filter, StringComparison.OrdinalIgnoreCase ) )
			return false;

		return bone.Classification switch
		{
			WeaponBoneClassification.WeaponRoot => _showMovable,
			WeaponBoneClassification.Animatable => _showMovable,
			WeaponBoneClassification.Structural => _showStructural,
			WeaponBoneClassification.Ignored => _showIgnored,
			_ => true
		};
	}

	private void CycleClassification( WeaponBoneDefinition selected )
	{
		if ( selected.Inclusion == WeaponBoneInclusion.Excluded
			|| selected.Classification == WeaponBoneClassification.WeaponRoot )
			return;

		var next = selected.Classification switch
		{
			WeaponBoneClassification.Animatable => WeaponBoneClassification.Structural,
			_ => WeaponBoneClassification.Animatable
		};

		_controller.Mutate( $"Classify {selected.Name}", document =>
		{
			selected.Classification = next;
			document.Rig.ReviewRequired = true;
			document.Rig.FilteredPreviewConfirmed = false;
			document.Source.PreviewHostCompiled = false;
			document.Calibration.Confirmed = false;
		} );
	}

	private void SetSelectedWeaponRoot()
	{
		var selected = _controller.Document.Workspace.SelectedBone;
		if ( string.IsNullOrWhiteSpace( selected ) )
			return;
		_controller.Mutate( $"Set weapon root {selected}", document =>
		{
			WeaponRigHierarchy.SelectWeaponSubtree( document.Rig, selected );
			document.Source.PreviewHostCompiled = false;
			document.Calibration.Confirmed = false;
		} );
	}

	private void ExcludeSelectedBranch()
	{
		var selected = _controller.Document.Workspace.SelectedBone;
		if ( string.IsNullOrWhiteSpace( selected ) )
			return;
		_controller.Mutate( $"Exclude branch {selected}", document =>
		{
			if ( !WeaponRigHierarchy.ExcludeBranch( document.Rig, selected ) )
				return;
			document.Source.PreviewHostCompiled = false;
			document.Calibration.Confirmed = false;
		} );
	}

	private void IncludeSelectedBranch()
	{
		var selected = _controller.Document.Workspace.SelectedBone;
		if ( string.IsNullOrWhiteSpace( selected ) )
			return;
		_controller.Mutate( $"Include branch {selected}", document =>
		{
			if ( !WeaponRigHierarchy.IncludeBranch( document.Rig, selected ) )
				return;
			document.Source.PreviewHostCompiled = false;
			document.Calibration.Confirmed = false;
		} );
	}

	private void ConfirmWeaponBones()
	{
		if ( _controller.Document.Rig.Bones.Count == 0 )
			return;
		_controller.Mutate( "Confirm filtered weapon bones", document =>
		{
			WeaponRigHierarchy.ConfirmFilteredPreview( document.Rig );
			document.Rig.ProfileHash = WeaponSourceImporter.HashText(
				WeaponRigHierarchy.ProfileText( document.Rig ) );
			document.Source.PreviewHostCompiled = false;
			document.Calibration.Confirmed = false;
		} );
		RigReviewConfirmed?.Invoke();
	}

	private static string ShortClassification( WeaponBoneClassification value ) => value switch
	{
		WeaponBoneClassification.WeaponRoot => "R",
		WeaponBoneClassification.Animatable => "A",
		WeaponBoneClassification.Structural => "S",
		_ => "×"
	};

	private Button Toggle( Widget parent, string text, bool value, Action<bool> changed )
	{
		var button = new WeaponAnimatorButton( text, parent )
		{
			IsToggle = true,
			IsChecked = value,
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Toggled = () =>
		{
			changed( button.IsChecked );
			RebuildBones();
		};
		return button;
	}

	internal static Widget Row( Widget parent )
	{
		var row = new Widget( parent );
		row.SetStyles( "background-color: transparent; border: none;" );
		row.Layout = Layout.Row();
		row.Layout.Margin = 0;
		row.Layout.Spacing = 4;
		return row;
	}
}

public sealed class CalibrationInspectorPanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly Label _selectedBone;
	private readonly Label _measurementResult;
	private readonly Label _alignmentResult;
	private readonly Label _confirmationState;
	private readonly LineEdit _knownDistance;
	private readonly List<Action> _transformRefreshers = [];
	private readonly List<Action> _documentRefreshers = [];
	private readonly Dictionary<Guid, Button> _customAnchorButtons = [];
	private Widget? _customAnchorCanvas;
	private string _customAnchorSignature = "";
	private Vector3 _modelDimensions;

	public event Action<ViewportPickMode, Guid>? PickRequested;
	public event Action? AutoAlignRequested;
	public event Action? ConfirmRequested;
	public event Action? RebuildPreviewRequested;

	public CalibrationInspectorPanel(
		WeaponAnimatorController controller,
		Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		var scroll = new ScrollArea( this );
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Add( scroll, 1 );

		var canvas = new Widget( scroll );
		canvas.Layout = Layout.Column();
		canvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin( 10 );
		canvas.Layout.Spacing = 8;
		scroll.Canvas = canvas;

		canvas.Layout.Add( Section( canvas, "SELECTION" ) );
		_selectedBone = WeaponAnimatorTheme.Label( "No bone selected", canvas, true );
		canvas.Layout.Add( _selectedBone );

		canvas.Layout.Add( Section( canvas, "PHYSICAL MEASUREMENT" ) );
		var pickRow = RigAuditPanel.Row( canvas );
		pickRow.Layout.Add( WeaponAnimatorTheme.Button(
			"Point A",
			"looks_one",
			() => PickRequested?.Invoke( ViewportPickMode.MeasurementFirst, Guid.Empty ),
			pickRow ), 1 );
		pickRow.Layout.Add( WeaponAnimatorTheme.Button(
			"Point B",
			"looks_two",
			() => PickRequested?.Invoke( ViewportPickMode.MeasurementSecond, Guid.Empty ),
			pickRow ), 1 );
		canvas.Layout.Add( pickRow );

		var knownRow = RigAuditPanel.Row( canvas );
		_knownDistance = new LineEdit( knownRow )
		{
			PlaceholderText = "Known distance",
			FixedHeight = 28
		};
		_knownDistance.SetStyles( WeaponAnimatorTheme.InputStyle );
		// EditingFinished fires on focus loss; ReturnPressed covers Enter explicitly so a typed
		// distance is committed even where the blur signal does not reach us.
		_knownDistance.EditingFinished += CommitScalePreview;
		_knownDistance.ReturnPressed += CommitScalePreview;
		knownRow.Layout.Add( _knownDistance, 1 );

		var unitButton = new WeaponAnimatorButton( "in", knownRow )
		{
			FixedWidth = 48,
			Clicked = ToggleUnit,
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		knownRow.Layout.Add( unitButton );
		_documentRefreshers.Add( () =>
		{
			unitButton.Text = _controller.Document.Calibration.Measurement.Unit
				== MeasurementUnit.Inches
					? "in"
					: "cm";
		} );
		canvas.Layout.Add( knownRow );

		_measurementResult = WeaponAnimatorTheme.Label( "Pick two points to establish scale.", canvas, true );
		_measurementResult.WordWrap = true;
		canvas.Layout.Add( _measurementResult );
		canvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Apply scale",
			"done",
			ApplyScale,
			canvas,
			true ) );

		canvas.Layout.Add( Section( canvas, "AUTO-ALIGN MARKERS · OPTIONAL" ) );
		var alignmentNote = WeaponAnimatorTheme.Label(
			"Only needed when Auto-align should rotate an incorrectly oriented source model.",
			canvas,
			true );
		alignmentNote.WordWrap = true;
		canvas.Layout.Add( alignmentNote );
		AddPickButton( canvas, "Alignment marker — rear", "radio_button_unchecked", ViewportPickMode.RearBoreAnchor );
		AddPickButton( canvas, "Alignment marker — front", "adjust", ViewportPickMode.FrontBoreAnchor );
		var autoAlign = WeaponAnimatorTheme.Button(
			"Auto-align from markers",
			"center_focus_strong",
			() => AutoAlignRequested?.Invoke(),
			canvas,
			true );
		autoAlign.ToolTip = "Uses the rear-to-front marker direction to rotate and place the weapon";
		canvas.Layout.Add( autoAlign );
		_alignmentResult = WeaponAnimatorTheme.Label(
			"Skip these markers when the source orientation is already correct.",
			canvas,
			true );
		_alignmentResult.WordWrap = true;
		canvas.Layout.Add( _alignmentResult );

		canvas.Layout.Add( Section( canvas, "GRIP ANCHOR · REQUIRED" ) );
		var gripNote = WeaponAnimatorTheme.Label(
			"Seeds the default primary-hand target used on the animation page.",
			canvas,
			true );
		gripNote.WordWrap = true;
		canvas.Layout.Add( gripNote );
		AddPickButton( canvas, "Primary grip", "pan_tool", ViewportPickMode.GripAnchor );

		canvas.Layout.Add( Section( canvas, "OUTPUT ANCHORS · OPTIONAL" ) );
		AddPickButton( canvas, "Muzzle", "flare", ViewportPickMode.MuzzleAnchor );
		AddPickButton( canvas, "Eject", "outbound", ViewportPickMode.EjectAnchor );

		canvas.Layout.Add( Section( canvas, "CUSTOM ANCHORS · OPTIONAL" ) );
		var customNote = WeaponAnimatorTheme.Label(
			"Exported alongside muzzle and eject. Name each one to match the attachment your game "
				+ "code expects. Grip and alignment markers stay in calibration and are never exported.",
			canvas,
			true );
		customNote.WordWrap = true;
		canvas.Layout.Add( customNote );
		_customAnchorCanvas = new Widget( canvas );
		_customAnchorCanvas.Layout = Layout.Column();
		_customAnchorCanvas.Layout.Margin = 0;
		_customAnchorCanvas.Layout.Spacing = 3;
		canvas.Layout.Add( _customAnchorCanvas );
		canvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Add custom anchor",
			"add_location_alt",
			AddCustomAnchor,
			canvas ) );
		_documentRefreshers.Add( RefreshCustomAnchors );

		canvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Clear all anchors",
			"delete_sweep",
			ClearAllAnchors,
			canvas ) );
		_documentRefreshers.Add( () =>
		{
			var calibration = _controller.Document.Calibration;
			autoAlign.Enabled = calibration.GetAnchor( AnchorKind.Grip ) is not null
				&& calibration.GetAnchor( AnchorKind.RearBore ) is not null
				&& calibration.GetAnchor( AnchorKind.FrontBore ) is not null;
		} );

		canvas.Layout.Add( Section( canvas, "NUMERIC TRANSFORMS" ) );
		AddTransformFields( canvas, "Physical", false );
		AddTransformFields( canvas, "Viewmodel framing", true );

		canvas.Layout.Add( Section( canvas, "PREVIEW" ) );
		var previewNote = WeaponAnimatorTheme.Label(
			"Viewmodel camera is optional. It previews a fixed origin and does not author the player's camera.",
			canvas,
			true );
		previewNote.WordWrap = true;
		canvas.Layout.Add( previewNote );
		var modeRow = RigAuditPanel.Row( canvas );
		modeRow.Layout.Add( Toggle(
			modeRow,
			"Viewmodel camera",
			() => _controller.Document.Workspace.FirstPersonPreview,
			value => _controller.Mutate( "Preview mode", d => d.Workspace.FirstPersonPreview = value ) ), 1 );
		modeRow.Layout.Add( Toggle(
			modeRow,
			"Safe area",
			() => _controller.Document.Calibration.ShowSafeArea,
			value => _controller.Mutate( "Safe area", d => d.Calibration.ShowSafeArea = value ) ), 1 );
		canvas.Layout.Add( modeRow );
		canvas.Layout.Add( ChoiceButton(
			canvas,
			"Aspect",
			() => _controller.Document.Calibration.AspectGuide,
			["4:3", "16:9", "21:9"],
			value => _controller.Mutate( "Aspect guide", d => d.Calibration.AspectGuide = value ) ) );
		canvas.Layout.Add( ChoiceButton(
			canvas,
			"Up axis",
			() => _controller.Document.Calibration.UpAxis.ToString(),
			Enum.GetNames<WeaponUpAxis>(),
			value => _controller.Mutate(
				"Alignment up axis",
				d => d.Calibration.UpAxis = Enum.Parse<WeaponUpAxis>( value ) ) ) );
		canvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Rebuild preview host",
			"refresh",
			() => RebuildPreviewRequested?.Invoke(),
			canvas ) );

		canvas.Layout.Add( Section( canvas, "CALIBRATION GATE" ) );
		_confirmationState = WeaponAnimatorTheme.Label( "", canvas, true );
		_confirmationState.WordWrap = true;
		canvas.Layout.Add( _confirmationState );
		canvas.Layout.Add( WeaponAnimatorTheme.Button(
			"Confirm rig and continue",
			"arrow_forward",
			() => ConfirmRequested?.Invoke(),
			canvas,
			true ) );
		canvas.Layout.AddStretchCell();

		_controller.DocumentChanged += Refresh;
		_controller.SelectionChanged += Refresh;
		_controller.PoseChanged += RefreshTransformValues;
		Refresh();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= Refresh;
		_controller.SelectionChanged -= Refresh;
		_controller.PoseChanged -= RefreshTransformValues;
		base.OnDestroyed();
	}

	public void SetModelDimensions( Vector3 dimensions )
	{
		_modelDimensions = dimensions;
		Refresh();
	}

	public void SetAlignmentMessage( string message )
	{
		_alignmentResult.Text = message;
	}

	private void Refresh()
	{
		var document = _controller.Document;
		if ( CalibrationSelection.TryGetAnchor( document.Workspace.SelectedControl, out var anchorKind ) )
		{
			var anchor = document.Calibration.GetAnchor( anchorKind );
			_selectedBone.Text = anchor is null
				? "Anchor not set"
				: $"{CalibrationSelection.DisplayName( anchorKind )} · use Move or Rotate in the viewport";
		}
		else
		{
			_selectedBone.Text = string.IsNullOrWhiteSpace( document.Workspace.SelectedBone )
				? "No control selected"
				: $"{document.Workspace.SelectedBone} bone";
		}

		var measurement = document.Calibration.Measurement;
		if ( !_knownDistance.IsFocused )
		{
			_knownDistance.Value = measurement.KnownDistance > 0
				? measurement.KnownDistance.ToString( "0.###", CultureInfo.InvariantCulture )
				: "";
		}
		PreviewScale();

		var report = WeaponAnimationValidator.ValidateCalibration( document );
		_confirmationState.Text = report.IsValid
			? "All calibration checks pass. Confirmation will snapshot the rig and seed Idle."
			: string.Join( "\n", report.Issues.Where( x => x.Blocking ).Take( 5 ).Select( x => $"• {x.Message}" ) );
		RefreshDocumentValues();
	}

	private void PreviewScale()
	{
		if ( !TryCalculateScalePreview( out _, out var preview ) )
		{
			_measurementResult.Text =
				"Pick two distinct points and enter a positive known distance.";
			return;
		}

		_measurementResult.Text =
			$"Measured {preview.MeasuredUnits:0.###} units. Scale ×{preview.UniformScale:0.####}\n" +
			$"Original XYZ: {FormatDimensions( preview.OriginalDimensions )}\n" +
			$"Result XYZ: {FormatDimensions( preview.ResultingDimensions )}";
	}

	private void CommitScalePreview()
	{
		var hasPreview = TryCalculateScalePreview( out var known, out var preview );
		if ( !float.TryParse(
			_knownDistance.Text,
			NumberStyles.Float,
			CultureInfo.InvariantCulture,
			out known )
			|| !WeaponAnimationMath.IsFinite( known )
			|| known <= 0 )
		{
			_measurementResult.Text = "Pick two distinct points and enter a positive known distance.";
			return;
		}

		_controller.Mutate( "Update scale measurement", document =>
		{
			var measurement = document.Calibration.Measurement;
			measurement.KnownDistance = known;
			measurement.HasPendingScale = hasPreview;
			if ( !hasPreview )
				return;
			measurement.PreviewScale = preview.UniformScale;
			measurement.OriginalDimensions = preview.OriginalDimensions;
			measurement.ResultingDimensions = preview.ResultingDimensions;
		} );
	}

	private void ApplyScale()
	{
		if ( !TryCalculateScalePreview( out var known, out var preview ) )
			return;

		_controller.Mutate( "Apply uniform scale", document =>
		{
			var measurement = document.Calibration.Measurement;
			measurement.KnownDistance = known;
			measurement.PreviewScale = preview.UniformScale;
			measurement.OriginalDimensions = preview.OriginalDimensions;
			measurement.ResultingDimensions = preview.ResultingDimensions;
			document.Calibration.UniformScale = preview.UniformScale;
			document.Calibration.PhysicalTransform =
				document.Calibration.PhysicalTransform.WithScale( preview.UniformScale );
			document.Calibration.Confirmed = false;
			measurement.HasPendingScale = false;
		} );
	}

	private bool TryCalculateScalePreview(
		out float known,
		out ScalePreview preview )
	{
		preview = default;
		if ( !float.TryParse(
				_knownDistance.Text,
				NumberStyles.Float,
				CultureInfo.InvariantCulture,
				out known )
			|| !WeaponAnimationMath.IsFinite( known )
			|| known <= 0 )
			return false;

		var measurement = _controller.Document.Calibration.Measurement;
		return measurement.HasFirstPoint
			&& measurement.HasSecondPoint
			&& WeaponAnimationMath.TryCalculateUniformScale(
				measurement.FirstPoint,
				measurement.SecondPoint,
				known,
				measurement.Unit,
				_modelDimensions,
				out preview );
	}

	private void RefreshTransformValues()
	{
		foreach ( var refresh in _transformRefreshers )
			refresh();
	}

	private void RefreshDocumentValues()
	{
		RefreshTransformValues();
		foreach ( var refresh in _documentRefreshers )
			refresh();
	}

	private void AddTransformFields( Widget parent, string label, bool framing )
	{
		parent.Layout.Add( WeaponAnimatorTheme.Label( label, parent, true ) );
		AddVectorField(
			parent,
			"Position",
			() => GetCalibrationTransform( framing ).Position,
			0.05f,
			(document, value) =>
				SetCalibrationTransform(
					document,
					framing,
					GetCalibrationTransform( document, framing ).WithPosition( value ) ) );
		AddVectorField(
			parent,
			"Rotation",
			() =>
			{
				var angles = GetCalibrationTransform( framing ).Rotation.Angles();
				return new Vector3( angles.pitch, angles.yaw, angles.roll );
			},
			0.5f,
			(document, value) =>
				SetCalibrationTransform(
					document,
					framing,
					GetCalibrationTransform( document, framing )
						.WithRotation( Rotation.From( value.x, value.y, value.z ) ) ) );
		AddScalarField(
			parent,
			"Scale",
			() => GetCalibrationTransform( framing ).Scale.x,
			0.005f,
			(document, value) =>
			{
				var clamped = MathF.Max( value, 0.0001f );
				SetCalibrationTransform(
					document,
					framing,
					GetCalibrationTransform( document, framing ).WithScale( clamped ) );
				if ( !framing )
					document.Calibration.UniformScale = clamped;
			} );
	}

	private void AddVectorField(
		Widget parent,
		string label,
		Func<Vector3> getter,
		float sensitivity,
		Action<WeaponAnimationDocument, Vector3> apply )
	{
		var row = RigAuditPanel.Row( parent );
		row.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );
		var edits = new LineEdit[3];
		var axisNames = new[] { "X", "Y", "Z" };
		var axisColors = new[]
		{
			WeaponAnimatorTheme.Coral,
			WeaponAnimatorTheme.Green,
			new Color( 0.30f, 0.56f, 0.96f )
		};
		for ( var index = 0; index < edits.Length; index++ )
		{
			var capturedIndex = index;
			var field = new Widget( row )
			{
				FixedWidth = 68,
				FixedHeight = 26,
				Layout = Layout.Row()
			};
			field.Layout.Margin = 0;
			field.Layout.Spacing = 0;
			var edit = new LineEdit( field ) { FixedWidth = 48, FixedHeight = 26 };
			edit.SetStyles( WeaponAnimatorTheme.InputStyle );
			edit.EditingFinished += () =>
			{
				var current = getter();
				if ( !float.TryParse(
					edit.Text,
					NumberStyles.Float,
					CultureInfo.InvariantCulture,
					out var parsed ) )
					return;
				current[capturedIndex] = parsed;
				_controller.Mutate(
					$"{label} {axisNames[capturedIndex]}",
					document => apply( document, current ) );
			};
			field.Layout.Add( new ScrubHandle(
				axisNames[capturedIndex],
				axisColors[capturedIndex],
				sensitivity,
				() => getter()[capturedIndex],
				() => _controller.BeginContinuousEdit( $"{label} {axisNames[capturedIndex]}" ),
				value =>
				{
					var current = getter();
					current[capturedIndex] = value;
					_controller.UpdateContinuousEdit( document => apply( document, current ) );
				},
				_controller.EndContinuousEdit,
				field ) );
			field.Layout.Add( edit );
			edits[index] = edit;
			row.Layout.Add( field );
		}
		_transformRefreshers.Add( () =>
		{
			var value = getter();
			for ( var index = 0; index < edits.Length; index++ )
				edits[index].Value = value[index].ToString( "0.###", CultureInfo.InvariantCulture );
		} );
		parent.Layout.Add( row );
	}

	private void AddScalarField(
		Widget parent,
		string label,
		Func<float> getter,
		float sensitivity,
		Action<WeaponAnimationDocument, float> apply )
	{
		var row = RigAuditPanel.Row( parent );
		row.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );
		var field = new Widget( row )
		{
			FixedWidth = 104,
			FixedHeight = 26,
			Layout = Layout.Row()
		};
		field.Layout.Margin = 0;
		field.Layout.Spacing = 0;
		var edit = new LineEdit( field ) { FixedWidth = 72, FixedHeight = 26 };
		edit.SetStyles( WeaponAnimatorTheme.InputStyle );
		edit.EditingFinished += () =>
		{
			if ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )
				_controller.Mutate( label, document => apply( document, parsed ) );
		};
		field.Layout.Add( new ScrubHandle(
			"XYZ",
			WeaponAnimatorTheme.Amber,
			sensitivity,
			getter,
			() => _controller.BeginContinuousEdit( label ),
			value => _controller.UpdateContinuousEdit( document => apply( document, value ) ),
			_controller.EndContinuousEdit,
			field )
		{
			FixedWidth = 32
		} );
		field.Layout.Add( edit );
		row.Layout.Add( field );
		_transformRefreshers.Add( () =>
			edit.Value = getter().ToString( "0.###", CultureInfo.InvariantCulture ) );
		parent.Layout.Add( row );
	}

	private Transform GetCalibrationTransform( bool framing ) =>
		GetCalibrationTransform( _controller.Document, framing );

	private static Transform GetCalibrationTransform( WeaponAnimationDocument document, bool framing ) =>
		framing ? document.Calibration.FramingTransform : document.Calibration.PhysicalTransform;

	private static void SetCalibrationTransform(
		WeaponAnimationDocument document,
		bool framing,
		Transform value )
	{
		if ( framing )
			document.Calibration.FramingTransform = value;
		else
		{
			document.Calibration.PhysicalTransform = value;
			document.Calibration.Confirmed = false;
		}
	}

	private static string FormatDimensions( Vector3 dimensions )
	{
		var centimetres = dimensions * WeaponAnimationMath.CentimetresPerInch;
		return $"{dimensions.x:0.##}×{dimensions.y:0.##}×{dimensions.z:0.##} in · " +
			$"{centimetres.x:0.##}×{centimetres.y:0.##}×{centimetres.z:0.##} cm";
	}

	private void ToggleUnit()
	{
		_controller.Mutate( "Measurement unit", document =>
		{
			var measurement = document.Calibration.Measurement;
			if ( measurement.Unit == MeasurementUnit.Inches )
			{
				measurement.Unit = MeasurementUnit.Centimetres;
				measurement.KnownDistance *= WeaponAnimationMath.CentimetresPerInch;
			}
			else
			{
				measurement.Unit = MeasurementUnit.Inches;
				measurement.KnownDistance /= WeaponAnimationMath.CentimetresPerInch;
			}
		} );
	}

	private void AddPickButton( Widget parent, string name, string icon, ViewportPickMode mode )
	{
		var kind = mode switch
		{
			ViewportPickMode.GripAnchor => AnchorKind.Grip,
			ViewportPickMode.RearBoreAnchor => AnchorKind.RearBore,
			ViewportPickMode.FrontBoreAnchor => AnchorKind.FrontBore,
			ViewportPickMode.MuzzleAnchor => AnchorKind.Muzzle,
			ViewportPickMode.EjectAnchor => AnchorKind.Eject,
			_ => AnchorKind.Custom
		};
		var row = RigAuditPanel.Row( parent );
		var select = WeaponAnimatorTheme.Button(
			name,
			icon,
			() =>
			{
				if ( _controller.Document.Calibration.GetAnchor( kind ) is null )
					PickRequested?.Invoke( mode, Guid.Empty );
				else
					_controller.SelectControl( CalibrationSelection.Anchor( kind ) );
			},
			row );
		row.Layout.Add( select, 1 );
		var repick = WeaponAnimatorTheme.Button(
			"",
			"my_location",
			() => PickRequested?.Invoke( mode, Guid.Empty ),
			row );
		repick.FixedWidth = 34;
		repick.ToolTip = $"Pick {name.ToLowerInvariant()} again";
		row.Layout.Add( repick );
		var delete = WeaponAnimatorTheme.Button(
			"",
			"delete",
			() => DeleteAnchor( kind ),
			row );
		delete.FixedWidth = 34;
		delete.ToolTip = $"Delete {name.ToLowerInvariant()}";
		row.Layout.Add( delete );
		_documentRefreshers.Add( () =>
		{
			var exists = _controller.Document.Calibration.GetAnchor( kind ) is not null;
			var selected = _controller.Document.Workspace.SelectedControl == CalibrationSelection.Anchor( kind );
			select.Text = exists ? $"Move {name}" : $"Set {name}";
			select.Tint = selected
				? WeaponAnimatorTheme.Cyan * 0.48f
				: WeaponAnimatorTheme.SurfaceRaised;
			repick.Enabled = exists;
			delete.Enabled = exists;
		} );
		parent.Layout.Add( row );
	}

	private void DeleteAnchor( AnchorKind kind )
	{
		if ( _controller.Document.Calibration.GetAnchor( kind ) is null )
			return;

		_controller.Mutate( $"Delete {CalibrationSelection.DisplayName( kind )} anchor", document =>
		{
			document.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == kind );
			if ( document.Workspace.SelectedControl == CalibrationSelection.Anchor( kind ) )
				document.Workspace.SelectedControl = "";
			document.Calibration.Confirmed = false;
		} );
	}

	/// <summary>
	/// Rebuilds the custom anchor rows only when the set actually changes, so selecting an anchor
	/// re-tints in place instead of tearing down live text fields the user may be editing.
	/// </summary>
	private void RefreshCustomAnchors()
	{
		if ( _customAnchorCanvas is null )
			return;

		var anchors = _controller.Document.Calibration.CustomAnchors().ToList();
		var signature = string.Join(
			'\n',
			anchors.Select( anchor => $"{anchor.Id:N}\t{anchor.GeneratedAttachmentName}" ) );
		if ( signature != _customAnchorSignature )
		{
			_customAnchorSignature = signature;
			_customAnchorCanvas.Layout.Clear( true );
			_customAnchorButtons.Clear();
			foreach ( var anchor in anchors )
				AddCustomAnchorRow( anchor );
		}

		var selected = _controller.Document.Workspace.SelectedControl;
		foreach ( var pair in _customAnchorButtons )
		{
			var anchor = _controller.Document.Calibration.FindAnchor( pair.Key );
			pair.Value.Tint = anchor is not null
				&& selected == CalibrationSelection.Anchor( anchor )
					? WeaponAnimatorTheme.Cyan * 0.48f
					: WeaponAnimatorTheme.SurfaceRaised;
		}
	}

	private void AddCustomAnchorRow( WeaponAnchor anchor )
	{
		var id = anchor.Id;
		var row = RigAuditPanel.Row( _customAnchorCanvas! );

		var edit = new LineEdit( row )
		{
			Text = WeaponAnimationNames.AttachmentName( anchor ),
			PlaceholderText = "attachment_name",
			FixedHeight = 27,
			ToolTip = "Attachment name written into the generated prefab and model"
		};
		edit.SetStyles( WeaponAnimatorTheme.InputStyle );
		// EditingFinished fires on focus loss; ReturnPressed covers Enter explicitly.
		edit.EditingFinished += () => RenameCustomAnchor( id, edit.Text );
		edit.ReturnPressed += () => RenameCustomAnchor( id, edit.Text );
		row.Layout.Add( edit, 1 );

		var select = WeaponAnimatorTheme.Button(
			"",
			"ads_click",
			() =>
			{
				if ( _controller.Document.Calibration.FindAnchor( id ) is { } target )
					_controller.SelectControl( CalibrationSelection.Anchor( target ) );
			},
			row );
		select.FixedWidth = 34;
		select.ToolTip = "Select this anchor and show its gizmo";
		_customAnchorButtons[id] = select;
		row.Layout.Add( select );

		var place = WeaponAnimatorTheme.Button(
			"",
			"my_location",
			() => PickRequested?.Invoke( ViewportPickMode.CustomAnchor, id ),
			row );
		place.FixedWidth = 34;
		place.ToolTip = "Click the weapon surface to place this anchor";
		row.Layout.Add( place );

		var remove = WeaponAnimatorTheme.Button(
			"",
			"delete",
			() => DeleteCustomAnchor( id ),
			row );
		remove.FixedWidth = 34;
		remove.ToolTip = "Delete this anchor";
		row.Layout.Add( remove );

		_customAnchorCanvas!.Layout.Add( row );
	}

	private void AddCustomAnchor()
	{
		var id = Guid.NewGuid();
		_controller.Mutate( "Add custom anchor", document =>
		{
			document.Calibration.Anchors.Add( new WeaponAnchor
			{
				Id = id,
				Kind = AnchorKind.Custom,
				Name = "attachment",
				BoneName = document.Workspace.SelectedBone
			} );
			WeaponAnimationNames.RepairCustomAnchorNames( document );
			document.Calibration.Confirmed = false;
		} );
		if ( _controller.Document.Calibration.FindAnchor( id ) is { } added )
			_controller.SelectControl( CalibrationSelection.Anchor( added ) );
	}

	/// <summary>
	/// The field edits the attachment name directly, so what the user types is what generation
	/// emits. Repair runs here rather than at generation time so any collision suffix is visible
	/// immediately instead of appearing silently in the output.
	/// </summary>
	private void RenameCustomAnchor( Guid id, string value )
	{
		_controller.Mutate( "Rename custom anchor", document =>
		{
			if ( document.Calibration.FindAnchor( id ) is not { } anchor )
				return;
			var slug = WeaponAnimationDocument.Slugify( value );
			if ( string.IsNullOrWhiteSpace( slug ) )
				slug = "anchor";
			if ( anchor.GeneratedAttachmentName == slug && anchor.Name == slug )
				return;
			anchor.GeneratedAttachmentName = slug;
			anchor.Name = slug;
			WeaponAnimationNames.RepairCustomAnchorNames( document );
			document.Calibration.Confirmed = false;
		} );
	}

	private void DeleteCustomAnchor( Guid id )
	{
		_controller.Mutate( "Delete custom anchor", document =>
		{
			if ( document.Calibration.FindAnchor( id ) is not { } anchor )
				return;
			var token = CalibrationSelection.Anchor( anchor );
			document.Calibration.Anchors.Remove( anchor );
			if ( document.Workspace.SelectedControl == token )
				document.Workspace.SelectedControl = "";
			document.Calibration.Confirmed = false;
		} );
	}

	private void ClearAllAnchors()
	{
		if ( _controller.Document.Calibration.Anchors.Count == 0 )
			return;

		_controller.Mutate( "Clear all anchors", document =>
		{
			document.Calibration.Anchors.Clear();
			if ( CalibrationSelection.TryGetAnchor( document.Workspace.SelectedControl, out _ ) )
				document.Workspace.SelectedControl = "";
			document.Calibration.Confirmed = false;
		} );
	}

	private static Label Section( Widget parent, string text )
	{
		return WeaponAnimatorTheme.SectionLabel( text, parent, topMargin: true );
	}

	private Button Toggle(
		Widget parent,
		string text,
		Func<bool> current,
		Action<bool> changed )
	{
		var button = new WeaponAnimatorButton( text, parent )
		{
			IsToggle = true,
			IsChecked = current(),
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Toggled = () => changed( button.IsChecked );
		_documentRefreshers.Add( () => button.IsChecked = current() );
		return button;
	}

	private Button ChoiceButton(
		Widget parent,
		string label,
		Func<string> current,
		System.Collections.Generic.IEnumerable<string> values,
		Action<string> changed )
	{
		var button = new WeaponAnimatorButton( $"{label}: {current()}", "expand_more", parent )
		{
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		button.Clicked = () =>
		{
			var menu = new Menu( button );
			foreach ( var value in values )
			{
				var captured = value;
				menu.AddOption( captured, null, () =>
				{
					changed( captured );
					button.Text = $"{label}: {current()}";
					button.FitToContent();
				} );
			}
			menu.OpenAt( button.ScreenRect.BottomLeft );
		};
		_documentRefreshers.Add( () =>
		{
			button.Text = $"{label}: {current()}";
			button.FitToContent();
		} );
		return button;
	}
}

public sealed class ValidationStatusPanel : Widget
{
	private readonly Label _label;

	public ValidationStatusPanel( Widget? parent = null ) : base( parent )
	{
		Layout = Layout.Row();
		Layout.Margin = new Sandbox.UI.Margin( 12, 8, 12, 8 );
		_label = WeaponAnimatorTheme.Label( "Ready", this, true );
		_label.WordWrap = true;
		Layout.Add( _label, 1 );
	}

	public void SetReport( ValidationReport report, string prefix = "" )
	{
		var status = report.IsValid
			? report.WarningCount == 0 ? "READY" : $"{report.WarningCount} WARNING(S)"
			: $"{report.ErrorCount} ERROR(S)";
		_label.Color = report.IsValid
			? report.WarningCount == 0 ? WeaponAnimatorTheme.Green : WeaponAnimatorTheme.Amber
			: WeaponAnimatorTheme.Coral;
		_label.Text = string.IsNullOrWhiteSpace( prefix )
			? $"{status} · {string.Join( "  ·  ", report.Issues.Take( 4 ).Select( x => x.Message ) )}"
			: $"{prefix} · {status} · {string.Join( "  ·  ", report.Issues.Take( 4 ).Select( x => x.Message ) )}";
	}

	public void SetMessage( string message, ValidationSeverity severity = ValidationSeverity.Info )
	{
		_label.Text = message;
		_label.Color = severity switch
		{
			ValidationSeverity.Error => WeaponAnimatorTheme.Coral,
			ValidationSeverity.Warning => WeaponAnimatorTheme.Amber,
			_ => WeaponAnimatorTheme.Muted
		};
	}
}
sonac.sbox-animator / Runtime/WeaponAnimationMath.cs
Game library
#nullable enable annotations

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

namespace SboxWeaponAnimator;

public readonly record struct ScalePreview(
	float MeasuredUnits,
	float KnownInches,
	float UniformScale,
	Vector3 OriginalDimensions,
	Vector3 ResultingDimensions );

public readonly record struct AlignmentResult(
	Transform PhysicalTransform,
	bool BoreMayBeReversed,
	Vector3 BoreDirection );

public readonly record struct TwoBoneSolution(
	Vector3 Root,
	Vector3 Elbow,
	Vector3 End,
	bool Reachable,
	float RequestedDistance,
	float SolvedDistance );

public static class WeaponAnimationMath
{
	public const float CentimetresPerInch = 2.54f;
	public const int MotionRateIntegrationSteps = 64;
	private const float Epsilon = 0.0001f;

	public static bool IsFinite( float value ) =>
		!float.IsNaN( value ) && !float.IsInfinity( value );

	public static bool IsFinite( Vector3 value ) =>
		IsFinite( value.x ) && IsFinite( value.y ) && IsFinite( value.z );

	public static bool TryCalculateUniformScale(
		Vector3 firstPoint,
		Vector3 secondPoint,
		float knownDistance,
		MeasurementUnit unit,
		Vector3 originalDimensions,
		out ScalePreview preview )
	{
		preview = default;
		var measuredUnits = firstPoint.Distance( secondPoint );
		var knownInches = unit == MeasurementUnit.Centimetres
			? knownDistance / CentimetresPerInch
			: knownDistance;

		if ( measuredUnits <= Epsilon || knownInches <= Epsilon )
			return false;

		var scale = knownInches / measuredUnits;
		if ( !IsFinite( scale ) || scale <= Epsilon )
			return false;

		preview = new ScalePreview(
			measuredUnits,
			knownInches,
			scale,
			originalDimensions,
			originalDimensions * scale );

		return true;
	}

	public static bool TryCalculateAlignment(
		Vector3 grip,
		Vector3 rearBore,
		Vector3 frontBore,
		WeaponUpAxis upAxis,
		float uniformScale,
		Vector3 canonicalGrip,
		out AlignmentResult result )
	{
		result = default;

		if ( !IsFinite( uniformScale ) || uniformScale <= Epsilon )
			return false;

		var scaledGrip = grip * uniformScale;
		var bore = (frontBore - rearBore) * uniformScale;
		if ( bore.Length <= Epsilon )
			return false;

		var forward = bore.Normal;
		var chosenUp = AxisVector( upAxis );
		var projectedUp = (chosenUp - forward * Vector3.Dot( chosenUp, forward )).Normal;
		if ( projectedUp.Length <= Epsilon )
			projectedUp = MathF.Abs( Vector3.Dot( forward, Vector3.Up ) ) < 0.95f
				? Vector3.Up
				: Vector3.Left;

		var sourceBasis = Rotation.LookAt( forward, projectedUp );
		var rotation = sourceBasis.Inverse;
		var rotatedGrip = rotation * scaledGrip;
		var position = canonicalGrip - rotatedGrip;
		var physical = new Transform( position, rotation, uniformScale );
		var reversed = Vector3.Dot( forward, Vector3.Forward ) < -0.25f;

		result = new AlignmentResult( physical, reversed, forward );
		return true;
	}

	public static Transform SampleTrack( TransformTrack track, float time, Transform fallback )
	{
		if ( track.Keys.Count == 0 || track.Muted )
			return fallback;

		var keys = track.Keys;
		if ( time <= keys[0].Time )
			return KeyTransform( keys[0] );
		if ( time >= keys[^1].Time )
			return KeyTransform( keys[^1] );

		var low = 0;
		var high = keys.Count - 1;
		while ( low < high )
		{
			var middle = low + (high - low) / 2;
			if ( keys[middle].Time < time )
				low = middle + 1;
			else
				high = middle;
		}

		if ( MathF.Abs( keys[low].Time - time ) <= Epsilon )
			return KeyTransform( keys[low] );
		return SampleSpan( track, keys[low - 1], keys[low], time );
	}

	private static Transform SampleSpan(
		TransformTrack track,
		TransformKey current,
		TransformKey next,
		float time )
	{
		var duration = MathF.Max( next.Time - current.Time, Epsilon );
		var fraction = Math.Clamp( (time - current.Time) / duration, 0.0f, 1.0f );
		var span = track.FindCurveSpan( current.Id, next.Id );
		var interpolation = span?.HasInterpolationOverride == true
			? span.Interpolation
			: track.Interpolation;
		var hasSpeedCurve = span?.HasSpeedCurve == true;
		if ( interpolation == TrackInterpolation.Stepped && !hasSpeedCurve )
			return KeyTransform( current );

		var progress = hasSpeedCurve
			? SampleMotionProgress( span!.Speed, fraction )
			: fraction;
		var valueInterpolation = hasSpeedCurve
			? TrackInterpolation.Linear
			: interpolation;
		if ( span is null || span.CustomChannels == TransformCurveChannel.None )
		{
			if ( valueInterpolation == TrackInterpolation.Cubic )
				progress = SmoothStep( progress );

			return new Transform(
				Vector3.Lerp( current.Position, next.Position, progress ),
				Rotation.Slerp( current.Rotation, next.Rotation, progress ),
				Vector3.Lerp( current.Scale, next.Scale, progress ) );
		}

		return new Transform(
			SampleVectorChannels(
				current.Position,
				next.Position,
				current.CurveTangents.PositionOut,
				next.CurveTangents.PositionIn,
				span.CustomChannels,
				TransformCurveChannel.PositionX,
				progress,
				duration,
				valueInterpolation ),
			SampleRotationChannels(
				current,
				next,
				span,
				progress,
				duration,
				valueInterpolation ),
			SampleVectorChannels(
				current.Scale,
				next.Scale,
				current.CurveTangents.ScaleOut,
				next.CurveTangents.ScaleIn,
				span.CustomChannels,
				TransformCurveChannel.ScaleX,
				progress,
				duration,
				valueInterpolation ) );
	}

	public static float SampleMotionRate( MotionRateCurve curve, float fraction )
	{
		fraction = Math.Clamp( fraction, 0.0f, 1.0f );
		var rate = Hermite(
			curve.StartRate,
			curve.EndRate,
			curve.StartSlope,
			curve.EndSlope,
			fraction );
		return IsFinite( rate ) ? MathF.Max( rate, 0 ) : 0;
	}

	public static float SampleMotionProgress( MotionRateCurve curve, float fraction )
	{
		fraction = Math.Clamp( fraction, 0.0f, 1.0f );
		if ( fraction <= 0 )
			return 0;
		if ( fraction >= 1 )
			return 1;

		var total = IntegrateMotionRate( curve, 1.0f );
		if ( total <= Epsilon || !IsFinite( total ) )
			return fraction;

		return Math.Clamp( IntegrateMotionRate( curve, fraction ) / total, 0.0f, 1.0f );
	}

	public static float MotionRateArea( MotionRateCurve curve ) =>
		IntegrateMotionRate( curve, 1.0f );

	public static float SnapTime( float time, float sampleRate, bool allowSubframes )
	{
		if ( allowSubframes || sampleRate <= Epsilon )
			return MathF.Max( time, 0 );

		return MathF.Max( MathF.Round( time * sampleRate ) / sampleRate, 0 );
	}

	public static TransformKey UpsertKey( TransformTrack track, float time, Transform value, float tolerance = 0.0001f )
	{
		var existing = track.Keys.FirstOrDefault( x => MathF.Abs( x.Time - time ) <= tolerance );
		if ( existing is null )
		{
			existing = new TransformKey { Time = time };
			track.Keys.Add( existing );
		}

		existing.Position = value.Position;
		existing.Rotation = value.Rotation.Normal;
		existing.Scale = value.Scale;
		track.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
		return existing;
	}

	public static void RepairCurveSpans( TransformTrack track )
	{
		var ordered = track.Keys.OrderBy( x => x.Time ).ToArray();
		var adjacent = ordered
			.Zip( ordered.Skip( 1 ), ( start, end ) => (start.Id, end.Id) )
			.ToHashSet();
		track.CurveSpans.RemoveAll( span =>
			span.StartKeyId == Guid.Empty
			|| span.EndKeyId == Guid.Empty
			|| !adjacent.Contains( (span.StartKeyId, span.EndKeyId) ) );

		foreach ( var duplicate in track.CurveSpans
			.GroupBy( x => (x.StartKeyId, x.EndKeyId) )
			.SelectMany( x => x.Skip( 1 ) )
			.ToArray() )
		{
			track.CurveSpans.Remove( duplicate );
		}
	}

	public static TwoBoneSolution SolveTwoBone(
		Vector3 root,
		Vector3 currentElbow,
		Vector3 currentEnd,
		Vector3 requestedTarget,
		Vector3 pole )
	{
		var upperLength = root.Distance( currentElbow );
		var lowerLength = currentElbow.Distance( currentEnd );
		var targetVector = requestedTarget - root;
		var requestedDistance = targetVector.Length;
		var direction = requestedDistance > Epsilon ? targetVector.Normal : Vector3.Forward;
		var minimum = MathF.Abs( upperLength - lowerLength ) + Epsilon;
		var maximum = MathF.Max( upperLength + lowerLength - Epsilon, minimum );
		var solvedDistance = Math.Clamp( requestedDistance, minimum, maximum );
		var reachable = requestedDistance >= minimum && requestedDistance <= maximum + Epsilon;
		var solvedEnd = root + direction * solvedDistance;

		var poleVector = pole - root;
		var poleDirection = poleVector - direction * Vector3.Dot( poleVector, direction );
		if ( poleDirection.Length <= Epsilon )
		{
			var fallback = MathF.Abs( Vector3.Dot( direction, Vector3.Up ) ) < 0.95f
				? Vector3.Up
				: Vector3.Left;
			poleDirection = fallback - direction * Vector3.Dot( fallback, direction );
		}

		poleDirection = poleDirection.Normal;
		var along = (
			upperLength * upperLength
			- lowerLength * lowerLength
			+ solvedDistance * solvedDistance ) / (2.0f * solvedDistance);
		var heightSquared = MathF.Max( upperLength * upperLength - along * along, 0 );
		var elbow = root + direction * along + poleDirection * MathF.Sqrt( heightSquared );
		return new TwoBoneSolution(
			root,
			elbow,
			solvedEnd,
			reachable,
			requestedDistance,
			solvedDistance );
	}

	public static Rotation RotationFromTo( Vector3 from, Vector3 to )
	{
		if ( from.Length <= Epsilon || to.Length <= Epsilon )
			return Rotation.Identity;

		from = from.Normal;
		to = to.Normal;
		var dot = Math.Clamp( Vector3.Dot( from, to ), -1.0f, 1.0f );
		var axis = Vector3.Cross( from, to );
		if ( axis.Length <= Epsilon )
		{
			if ( dot >= 0 )
				return Rotation.Identity;

			var orthogonal = Vector3.Cross( from, Vector3.Up );
			if ( orthogonal.Length <= Epsilon )
				orthogonal = Vector3.Cross( from, Vector3.Right );
			return Rotation.FromAxis( orthogonal.Normal, 180.0f );
		}

		return Rotation.FromAxis(
			axis.Normal,
			MathF.Acos( dot ).RadianToDegree() );
	}

	public static Transform Compose( Transform physical, Transform framing )
	{
		var position = physical.PointToWorld( framing.Position );
		var rotation = physical.Rotation * framing.Rotation;
		var scale = physical.Scale * framing.Scale;
		return new Transform( position, rotation, scale );
	}

	public static float ToCentimetres( float sboxUnits ) => sboxUnits * CentimetresPerInch;

	public static Vector3 AxisVector( WeaponUpAxis axis ) => axis switch
	{
		WeaponUpAxis.NegativeZ => Vector3.Down,
		WeaponUpAxis.PositiveY => Vector3.Left,
		WeaponUpAxis.NegativeY => Vector3.Right,
		_ => Vector3.Up
	};

	private static Transform KeyTransform( TransformKey key ) =>
		new( key.Position, key.Rotation.Normal, key.Scale );

	private static float IntegrateMotionRate( MotionRateCurve curve, float end )
	{
		end = Math.Clamp( end, 0.0f, 1.0f );
		if ( end <= 0 )
			return 0;

		var step = 1.0f / MotionRateIntegrationSteps;
		var wholeSteps = Math.Clamp(
			(int)MathF.Floor( end * MotionRateIntegrationSteps ),
			0,
			MotionRateIntegrationSteps );
		var area = 0.0f;
		for ( var index = 0; index < wholeSteps; index++ )
		{
			var start = index * step;
			var finish = (index + 1) * step;
			area += (SampleMotionRate( curve, start ) + SampleMotionRate( curve, finish ))
				* 0.5f * step;
		}

		var remainderStart = wholeSteps * step;
		if ( remainderStart < end )
		{
			area += (SampleMotionRate( curve, remainderStart ) + SampleMotionRate( curve, end ))
				* 0.5f * (end - remainderStart);
		}
		return area;
	}

	private static Vector3 SampleVectorChannels(
		Vector3 start,
		Vector3 end,
		Vector3 startTangents,
		Vector3 endTangents,
		TransformCurveChannel customChannels,
		TransformCurveChannel firstChannel,
		float progress,
		float duration,
		TrackInterpolation interpolation )
	{
		var legacy = interpolation == TrackInterpolation.Cubic
			? SmoothStep( progress )
			: progress;
		return new Vector3(
			SampleScalarChannel(
				start.x, end.x, startTangents.x, endTangents.x,
				(customChannels & firstChannel) != 0, progress, legacy, duration ),
			SampleScalarChannel(
				start.y, end.y, startTangents.y, endTangents.y,
				(customChannels & (TransformCurveChannel)((int)firstChannel << 1)) != 0,
				progress, legacy, duration ),
			SampleScalarChannel(
				start.z, end.z, startTangents.z, endTangents.z,
				(customChannels & (TransformCurveChannel)((int)firstChannel << 2)) != 0,
				progress, legacy, duration ) );
	}

	private static float SampleScalarChannel(
		float start,
		float end,
		float startTangent,
		float endTangent,
		bool custom,
		float progress,
		float legacyProgress,
		float duration ) =>
		custom
			? Hermite( start, end, startTangent * duration, endTangent * duration, progress )
			: start.LerpTo( end, legacyProgress );

	private static Rotation SampleRotationChannels(
		TransformKey current,
		TransformKey next,
		TransformCurveSpan span,
		float progress,
		float duration,
		TrackInterpolation interpolation )
	{
		var custom = span.CustomChannels & TransformCurveChannel.Rotation;
		var legacy = interpolation == TrackInterpolation.Cubic
			? SmoothStep( progress )
			: progress;
		if ( custom == TransformCurveChannel.None )
			return Rotation.Slerp( current.Rotation, next.Rotation, legacy );

		var startAngles = current.Rotation.Angles();
		var endAngles = next.Rotation.Angles();
		var start = new Vector3( startAngles.pitch, startAngles.yaw, startAngles.roll );
		var end = new Vector3(
			UnwrapDegrees( start.x, endAngles.pitch ),
			UnwrapDegrees( start.y, endAngles.yaw ),
			UnwrapDegrees( start.z, endAngles.roll ) );
		var legacyRotation = Rotation.Slerp( current.Rotation, next.Rotation, legacy );
		var legacyAngles = legacyRotation.Angles();
		var legacyValues = new Vector3(
			UnwrapDegrees( start.x, legacyAngles.pitch ),
			UnwrapDegrees( start.y, legacyAngles.yaw ),
			UnwrapDegrees( start.z, legacyAngles.roll ) );
		var sampled = new Vector3(
			(custom & TransformCurveChannel.RotationX) != 0
				? Hermite(
					start.x,
					end.x,
					current.CurveTangents.RotationOut.x * duration,
					next.CurveTangents.RotationIn.x * duration,
					progress )
				: legacyValues.x,
			(custom & TransformCurveChannel.RotationY) != 0
				? Hermite(
					start.y,
					end.y,
					current.CurveTangents.RotationOut.y * duration,
					next.CurveTangents.RotationIn.y * duration,
					progress )
				: legacyValues.y,
			(custom & TransformCurveChannel.RotationZ) != 0
				? Hermite(
					start.z,
					end.z,
					current.CurveTangents.RotationOut.z * duration,
					next.CurveTangents.RotationIn.z * duration,
					progress )
				: legacyValues.z );
		return Rotation.From( new Angles( sampled.x, sampled.y, sampled.z ) ).Normal;
	}

	private static float Hermite(
		float start,
		float end,
		float startTangent,
		float endTangent,
		float amount )
	{
		var amount2 = amount * amount;
		var amount3 = amount2 * amount;
		return (2 * amount3 - 3 * amount2 + 1) * start
			+ (amount3 - 2 * amount2 + amount) * startTangent
			+ (-2 * amount3 + 3 * amount2) * end
			+ (amount3 - amount2) * endTangent;
	}

	private static float SmoothStep( float amount ) =>
		amount * amount * (3.0f - 2.0f * amount);

	private static float UnwrapDegrees( float reference, float value )
	{
		var difference = (value - reference) % 360.0f;
		if ( difference > 180 )
			difference -= 360;
		else if ( difference < -180 )
			difference += 360;
		return reference + difference;
	}
}

public static class ClipConstraintEvaluator
{
	public static Transform Apply(
		Transform source,
		Transform target,
		TimedConstraint constraint,
		float time,
		Transform maintainedOffset )
	{
		if ( time < constraint.StartTime || time > constraint.EndTime || constraint.Weight <= 0 )
			return source;

		var desired = constraint.MaintainOffset
			? new Transform(
				target.PointToWorld( maintainedOffset.Position ),
				target.Rotation * maintainedOffset.Rotation,
				target.Scale * maintainedOffset.Scale )
			: target;

		var weight = Math.Clamp( constraint.Weight, 0.0f, 1.0f );
		return new Transform(
			Vector3.Lerp( source.Position, desired.Position, weight ),
			Rotation.Slerp( source.Rotation, desired.Rotation, weight ),
			Vector3.Lerp( source.Scale, desired.Scale, weight ) );
	}
}
sonac.sbox-animator / Runtime/WeaponRigHierarchy.cs
Game library
#nullable enable annotations

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

namespace SboxWeaponAnimator;

public static class WeaponRigHierarchy
{
	public static void RepairMetadata( WeaponRigDefinition rig, bool legacyBindTransforms )
	{
		var byName = rig.Bones
			.GroupBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
			.ToDictionary( x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase );
		var paths = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );

		string ResolvePath( WeaponBoneDefinition bone, HashSet<string> visiting )
		{
			if ( paths.TryGetValue( bone.Name, out var existing ) )
				return existing;
			if ( !visiting.Add( bone.Name ) )
				return EscapePathPart( bone.Name );

			var own = EscapePathPart( bone.Name );
			if ( !string.IsNullOrWhiteSpace( bone.ParentName )
				&& byName.TryGetValue( bone.ParentName, out var parent ) )
			{
				own = $"{ResolvePath( parent, visiting )}/{own}";
			}

			visiting.Remove( bone.Name );
			paths[bone.Name] = own;
			return own;
		}

		foreach ( var bone in rig.Bones )
		{
			bone.HierarchyPath = ResolvePath( bone, [] );
			bone.Id = bone.HierarchyPath;
			bone.OriginalName = string.IsNullOrWhiteSpace( bone.OriginalName )
				? bone.Name
				: bone.OriginalName;
			bone.OriginalParentName = string.IsNullOrWhiteSpace( bone.OriginalParentName )
				? bone.ParentName
				: bone.OriginalParentName;

			if ( legacyBindTransforms )
				bone.BindModelTransform = bone.BindTransform;
			else
				bone.BindTransform = bone.BindModelTransform;
		}

		foreach ( var bone in rig.Bones )
		{
			var parent = string.IsNullOrWhiteSpace( bone.ParentName )
				? null
				: rig.FindBone( bone.ParentName );
			bone.ParentId = parent?.Id ?? "";
			bone.BindLocalTransform = parent is null
				? bone.BindModelTransform
				: parent.BindModelTransform.ToLocal( bone.BindModelTransform );
		}

		var sourceRoot = rig.Bones.FirstOrDefault( x => string.IsNullOrWhiteSpace( x.ParentId ) );
		rig.SourceSkeletonRootId = sourceRoot?.Id ?? "";
		var weaponRoot = rig.Bones.FirstOrDefault( x =>
			x.Classification == WeaponBoneClassification.WeaponRoot );
		if ( weaponRoot is not null )
		{
			rig.RootBone = weaponRoot.Name;
			rig.WeaponSubtreeRootId = weaponRoot.Id;
		}
	}

	public static bool SelectWeaponSubtree( WeaponRigDefinition rig, string idOrName )
	{
		var selected = rig.FindBone( idOrName );
		if ( selected is null )
			return false;

		var descendants = DescendantIds( rig, selected.Id );
		var ancestors = AncestorIds( rig, selected );
		foreach ( var bone in rig.Bones )
		{
			if ( bone.Id.Equals( selected.Id, StringComparison.OrdinalIgnoreCase ) )
			{
				bone.Inclusion = WeaponBoneInclusion.Included;
				bone.Classification = WeaponBoneClassification.WeaponRoot;
			}
			else if ( descendants.Contains( bone.Id ) )
			{
				bone.Inclusion = WeaponBoneInclusion.Included;
				if ( bone.Classification is WeaponBoneClassification.Ignored
					or WeaponBoneClassification.WeaponRoot )
					bone.Classification = WeaponBoneClassification.Animatable;
			}
			else if ( ancestors.Contains( bone.Id ) )
			{
				bone.Inclusion = WeaponBoneInclusion.StructuralBridge;
				bone.Classification = WeaponBoneClassification.Structural;
			}
			else
			{
				bone.Inclusion = WeaponBoneInclusion.Excluded;
				bone.Classification = WeaponBoneClassification.Ignored;
			}
		}

		rig.RootBone = selected.Name;
		rig.WeaponSubtreeRootId = selected.Id;
		RequireReview( rig );
		return true;
	}

	public static bool ExcludeBranch( WeaponRigDefinition rig, string idOrName )
	{
		var selected = rig.FindBone( idOrName );
		if ( selected is null || string.IsNullOrWhiteSpace( rig.WeaponSubtreeRootId ) )
			return false;

		var protectedIds = AncestorIds(
			rig,
			rig.FindBone( rig.WeaponSubtreeRootId ) ?? selected );
		protectedIds.Add( rig.WeaponSubtreeRootId );
		if ( protectedIds.Contains( selected.Id ) )
			return false;

		var branch = DescendantIds( rig, selected.Id );
		branch.Add( selected.Id );
		foreach ( var bone in rig.Bones.Where( x => branch.Contains( x.Id ) ) )
		{
			bone.Inclusion = WeaponBoneInclusion.Excluded;
			bone.Classification = WeaponBoneClassification.Ignored;
		}

		RequireReview( rig );
		return true;
	}

	public static bool IncludeBranch( WeaponRigDefinition rig, string idOrName )
	{
		var selected = rig.FindBone( idOrName );
		var root = rig.FindBone( rig.WeaponSubtreeRootId );
		if ( selected is null || root is null )
			return false;

		var rootBranch = DescendantIds( rig, root.Id );
		rootBranch.Add( root.Id );
		if ( !rootBranch.Contains( selected.Id ) )
			return false;

		var branch = DescendantIds( rig, selected.Id );
		branch.Add( selected.Id );
		foreach ( var bone in rig.Bones.Where( x => branch.Contains( x.Id ) ) )
		{
			bone.Inclusion = WeaponBoneInclusion.Included;
			bone.Classification = bone.Id.Equals( root.Id, StringComparison.OrdinalIgnoreCase )
				? WeaponBoneClassification.WeaponRoot
				: WeaponBoneClassification.Animatable;
		}

		RequireReview( rig );
		return true;
	}

	public static void ConfirmFilteredPreview( WeaponRigDefinition rig )
	{
		rig.ReviewRequired = false;
		rig.FilteredPreviewConfirmed = true;
	}

	public static bool IsRetained( WeaponBoneDefinition bone ) =>
		bone.Inclusion != WeaponBoneInclusion.Excluded
		&& bone.Classification != WeaponBoneClassification.Ignored;

	public static string ProfileText( WeaponRigDefinition rig ) => string.Join(
		"\n",
		rig.Bones
			.OrderBy( x => x.Id, StringComparer.OrdinalIgnoreCase )
			.Select( x =>
				$"{x.Id}|{x.ParentId}|{x.Name}|{x.Classification}|{x.Inclusion}|"
				+ $"{x.BindModelTransform}|{x.BindLocalTransform}" ) );

	private static HashSet<string> DescendantIds( WeaponRigDefinition rig, string rootId )
	{
		var result = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var pending = new Queue<string>();
		pending.Enqueue( rootId );
		while ( pending.Count > 0 )
		{
			var parentId = pending.Dequeue();
			foreach ( var child in rig.Bones.Where( x =>
				x.ParentId.Equals( parentId, StringComparison.OrdinalIgnoreCase ) ) )
			{
				if ( result.Add( child.Id ) )
					pending.Enqueue( child.Id );
			}
		}
		return result;
	}

	private static HashSet<string> AncestorIds(
		WeaponRigDefinition rig,
		WeaponBoneDefinition bone )
	{
		var result = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		var parentId = bone.ParentId;
		while ( !string.IsNullOrWhiteSpace( parentId ) && result.Add( parentId ) )
			parentId = rig.FindBone( parentId )?.ParentId ?? "";
		return result;
	}

	private static void RequireReview( WeaponRigDefinition rig )
	{
		rig.ReviewRequired = true;
		rig.FilteredPreviewConfirmed = false;
	}

	private static string EscapePathPart( string value ) =>
		value.Replace( "%", "%25", StringComparison.Ordinal )
			.Replace( "/", "%2F", StringComparison.Ordinal );
}
sonac.sbox-animator / .obj/__compiler_extra.cs
Game library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "S&box Weapon Animator" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "sbox-animator" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "sonac" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "sonac.sbox-animator" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "28" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]

[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-07-29T19:18:21.4795109Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.115.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.115.0")]
sonac.sbox-animator / Code/Runtime/WeaponAnimationMath.cs
Game library
#nullable enable annotations

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

namespace SboxWeaponAnimator;

public readonly record struct ScalePreview(
	float MeasuredUnits,
	float KnownInches,
	float UniformScale,
	Vector3 OriginalDimensions,
	Vector3 ResultingDimensions );

public readonly record struct AlignmentResult(
	Transform PhysicalTransform,
	bool BoreMayBeReversed,
	Vector3 BoreDirection );

public readonly record struct TwoBoneSolution(
	Vector3 Root,
	Vector3 Elbow,
	Vector3 End,
	bool Reachable,
	float RequestedDistance,
	float SolvedDistance );

public static class WeaponAnimationMath
{
	public const float CentimetresPerInch = 2.54f;
	public const int MotionRateIntegrationSteps = 64;
	private const float Epsilon = 0.0001f;

	public static bool IsFinite( float value ) =>
		!float.IsNaN( value ) && !float.IsInfinity( value );

	public static bool IsFinite( Vector3 value ) =>
		IsFinite( value.x ) && IsFinite( value.y ) && IsFinite( value.z );

	public static bool TryCalculateUniformScale(
		Vector3 firstPoint,
		Vector3 secondPoint,
		float knownDistance,
		MeasurementUnit unit,
		Vector3 originalDimensions,
		out ScalePreview preview )
	{
		preview = default;
		var measuredUnits = firstPoint.Distance( secondPoint );
		var knownInches = unit == MeasurementUnit.Centimetres
			? knownDistance / CentimetresPerInch
			: knownDistance;

		if ( measuredUnits <= Epsilon || knownInches <= Epsilon )
			return false;

		var scale = knownInches / measuredUnits;
		if ( !IsFinite( scale ) || scale <= Epsilon )
			return false;

		preview = new ScalePreview(
			measuredUnits,
			knownInches,
			scale,
			originalDimensions,
			originalDimensions * scale );

		return true;
	}

	public static bool TryCalculateAlignment(
		Vector3 grip,
		Vector3 rearBore,
		Vector3 frontBore,
		WeaponUpAxis upAxis,
		float uniformScale,
		Vector3 canonicalGrip,
		out AlignmentResult result )
	{
		result = default;

		if ( !IsFinite( uniformScale ) || uniformScale <= Epsilon )
			return false;

		var scaledGrip = grip * uniformScale;
		var bore = (frontBore - rearBore) * uniformScale;
		if ( bore.Length <= Epsilon )
			return false;

		var forward = bore.Normal;
		var chosenUp = AxisVector( upAxis );
		var projectedUp = (chosenUp - forward * Vector3.Dot( chosenUp, forward )).Normal;
		if ( projectedUp.Length <= Epsilon )
			projectedUp = MathF.Abs( Vector3.Dot( forward, Vector3.Up ) ) < 0.95f
				? Vector3.Up
				: Vector3.Left;

		var sourceBasis = Rotation.LookAt( forward, projectedUp );
		var rotation = sourceBasis.Inverse;
		var rotatedGrip = rotation * scaledGrip;
		var position = canonicalGrip - rotatedGrip;
		var physical = new Transform( position, rotation, uniformScale );
		var reversed = Vector3.Dot( forward, Vector3.Forward ) < -0.25f;

		result = new AlignmentResult( physical, reversed, forward );
		return true;
	}

	public static Transform SampleTrack( TransformTrack track, float time, Transform fallback )
	{
		if ( track.Keys.Count == 0 || track.Muted )
			return fallback;

		var keys = track.Keys;
		if ( time <= keys[0].Time )
			return KeyTransform( keys[0] );
		if ( time >= keys[^1].Time )
			return KeyTransform( keys[^1] );

		var low = 0;
		var high = keys.Count - 1;
		while ( low < high )
		{
			var middle = low + (high - low) / 2;
			if ( keys[middle].Time < time )
				low = middle + 1;
			else
				high = middle;
		}

		if ( MathF.Abs( keys[low].Time - time ) <= Epsilon )
			return KeyTransform( keys[low] );
		return SampleSpan( track, keys[low - 1], keys[low], time );
	}

	private static Transform SampleSpan(
		TransformTrack track,
		TransformKey current,
		TransformKey next,
		float time )
	{
		var duration = MathF.Max( next.Time - current.Time, Epsilon );
		var fraction = Math.Clamp( (time - current.Time) / duration, 0.0f, 1.0f );
		var span = track.FindCurveSpan( current.Id, next.Id );
		var interpolation = span?.HasInterpolationOverride == true
			? span.Interpolation
			: track.Interpolation;
		var hasSpeedCurve = span?.HasSpeedCurve == true;
		if ( interpolation == TrackInterpolation.Stepped && !hasSpeedCurve )
			return KeyTransform( current );

		var progress = hasSpeedCurve
			? SampleMotionProgress( span!.Speed, fraction )
			: fraction;
		var valueInterpolation = hasSpeedCurve
			? TrackInterpolation.Linear
			: interpolation;
		if ( span is null || span.CustomChannels == TransformCurveChannel.None )
		{
			if ( valueInterpolation == TrackInterpolation.Cubic )
				progress = SmoothStep( progress );

			return new Transform(
				Vector3.Lerp( current.Position, next.Position, progress ),
				Rotation.Slerp( current.Rotation, next.Rotation, progress ),
				Vector3.Lerp( current.Scale, next.Scale, progress ) );
		}

		return new Transform(
			SampleVectorChannels(
				current.Position,
				next.Position,
				current.CurveTangents.PositionOut,
				next.CurveTangents.PositionIn,
				span.CustomChannels,
				TransformCurveChannel.PositionX,
				progress,
				duration,
				valueInterpolation ),
			SampleRotationChannels(
				current,
				next,
				span,
				progress,
				duration,
				valueInterpolation ),
			SampleVectorChannels(
				current.Scale,
				next.Scale,
				current.CurveTangents.ScaleOut,
				next.CurveTangents.ScaleIn,
				span.CustomChannels,
				TransformCurveChannel.ScaleX,
				progress,
				duration,
				valueInterpolation ) );
	}

	public static float SampleMotionRate( MotionRateCurve curve, float fraction )
	{
		fraction = Math.Clamp( fraction, 0.0f, 1.0f );
		var rate = Hermite(
			curve.StartRate,
			curve.EndRate,
			curve.StartSlope,
			curve.EndSlope,
			fraction );
		return IsFinite( rate ) ? MathF.Max( rate, 0 ) : 0;
	}

	public static float SampleMotionProgress( MotionRateCurve curve, float fraction )
	{
		fraction = Math.Clamp( fraction, 0.0f, 1.0f );
		if ( fraction <= 0 )
			return 0;
		if ( fraction >= 1 )
			return 1;

		var total = IntegrateMotionRate( curve, 1.0f );
		if ( total <= Epsilon || !IsFinite( total ) )
			return fraction;

		return Math.Clamp( IntegrateMotionRate( curve, fraction ) / total, 0.0f, 1.0f );
	}

	public static float MotionRateArea( MotionRateCurve curve ) =>
		IntegrateMotionRate( curve, 1.0f );

	public static float SnapTime( float time, float sampleRate, bool allowSubframes )
	{
		if ( allowSubframes || sampleRate <= Epsilon )
			return MathF.Max( time, 0 );

		return MathF.Max( MathF.Round( time * sampleRate ) / sampleRate, 0 );
	}

	public static TransformKey UpsertKey( TransformTrack track, float time, Transform value, float tolerance = 0.0001f )
	{
		var existing = track.Keys.FirstOrDefault( x => MathF.Abs( x.Time - time ) <= tolerance );
		if ( existing is null )
		{
			existing = new TransformKey { Time = time };
			track.Keys.Add( existing );
		}

		existing.Position = value.Position;
		existing.Rotation = value.Rotation.Normal;
		existing.Scale = value.Scale;
		track.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );
		return existing;
	}

	public static void RepairCurveSpans( TransformTrack track )
	{
		var ordered = track.Keys.OrderBy( x => x.Time ).ToArray();
		var adjacent = ordered
			.Zip( ordered.Skip( 1 ), ( start, end ) => (start.Id, end.Id) )
			.ToHashSet();
		track.CurveSpans.RemoveAll( span =>
			span.StartKeyId == Guid.Empty
			|| span.EndKeyId == Guid.Empty
			|| !adjacent.Contains( (span.StartKeyId, span.EndKeyId) ) );

		foreach ( var duplicate in track.CurveSpans
			.GroupBy( x => (x.StartKeyId, x.EndKeyId) )
			.SelectMany( x => x.Skip( 1 ) )
			.ToArray() )
		{
			track.CurveSpans.Remove( duplicate );
		}
	}

	public static TwoBoneSolution SolveTwoBone(
		Vector3 root,
		Vector3 currentElbow,
		Vector3 currentEnd,
		Vector3 requestedTarget,
		Vector3 pole )
	{
		var upperLength = root.Distance( currentElbow );
		var lowerLength = currentElbow.Distance( currentEnd );
		var targetVector = requestedTarget - root;
		var requestedDistance = targetVector.Length;
		var direction = requestedDistance > Epsilon ? targetVector.Normal : Vector3.Forward;
		var minimum = MathF.Abs( upperLength - lowerLength ) + Epsilon;
		var maximum = MathF.Max( upperLength + lowerLength - Epsilon, minimum );
		var solvedDistance = Math.Clamp( requestedDistance, minimum, maximum );
		var reachable = requestedDistance >= minimum && requestedDistance <= maximum + Epsilon;
		var solvedEnd = root + direction * solvedDistance;

		var poleVector = pole - root;
		var poleDirection = poleVector - direction * Vector3.Dot( poleVector, direction );
		if ( poleDirection.Length <= Epsilon )
		{
			var fallback = MathF.Abs( Vector3.Dot( direction, Vector3.Up ) ) < 0.95f
				? Vector3.Up
				: Vector3.Left;
			poleDirection = fallback - direction * Vector3.Dot( fallback, direction );
		}

		poleDirection = poleDirection.Normal;
		var along = (
			upperLength * upperLength
			- lowerLength * lowerLength
			+ solvedDistance * solvedDistance ) / (2.0f * solvedDistance);
		var heightSquared = MathF.Max( upperLength * upperLength - along * along, 0 );
		var elbow = root + direction * along + poleDirection * MathF.Sqrt( heightSquared );
		return new TwoBoneSolution(
			root,
			elbow,
			solvedEnd,
			reachable,
			requestedDistance,
			solvedDistance );
	}

	public static Rotation RotationFromTo( Vector3 from, Vector3 to )
	{
		if ( from.Length <= Epsilon || to.Length <= Epsilon )
			return Rotation.Identity;

		from = from.Normal;
		to = to.Normal;
		var dot = Math.Clamp( Vector3.Dot( from, to ), -1.0f, 1.0f );
		var axis = Vector3.Cross( from, to );
		if ( axis.Length <= Epsilon )
		{
			if ( dot >= 0 )
				return Rotation.Identity;

			var orthogonal = Vector3.Cross( from, Vector3.Up );
			if ( orthogonal.Length <= Epsilon )
				orthogonal = Vector3.Cross( from, Vector3.Right );
			return Rotation.FromAxis( orthogonal.Normal, 180.0f );
		}

		return Rotation.FromAxis(
			axis.Normal,
			MathF.Acos( dot ).RadianToDegree() );
	}

	public static Transform Compose( Transform physical, Transform framing )
	{
		var position = physical.PointToWorld( framing.Position );
		var rotation = physical.Rotation * framing.Rotation;
		var scale = physical.Scale * framing.Scale;
		return new Transform( position, rotation, scale );
	}

	public static float ToCentimetres( float sboxUnits ) => sboxUnits * CentimetresPerInch;

	public static Vector3 AxisVector( WeaponUpAxis axis ) => axis switch
	{
		WeaponUpAxis.NegativeZ => Vector3.Down,
		WeaponUpAxis.PositiveY => Vector3.Left,
		WeaponUpAxis.NegativeY => Vector3.Right,
		_ => Vector3.Up
	};

	private static Transform KeyTransform( TransformKey key ) =>
		new( key.Position, key.Rotation.Normal, key.Scale );

	private static float IntegrateMotionRate( MotionRateCurve curve, float end )
	{
		end = Math.Clamp( end, 0.0f, 1.0f );
		if ( end <= 0 )
			return 0;

		var step = 1.0f / MotionRateIntegrationSteps;
		var wholeSteps = Math.Clamp(
			(int)MathF.Floor( end * MotionRateIntegrationSteps ),
			0,
			MotionRateIntegrationSteps );
		var area = 0.0f;
		for ( var index = 0; index < wholeSteps; index++ )
		{
			var start = index * step;
			var finish = (index + 1) * step;
			area += (SampleMotionRate( curve, start ) + SampleMotionRate( curve, finish ))
				* 0.5f * step;
		}

		var remainderStart = wholeSteps * step;
		if ( remainderStart < end )
		{
			area += (SampleMotionRate( curve, remainderStart ) + SampleMotionRate( curve, end ))
				* 0.5f * (end - remainderStart);
		}
		return area;
	}

	private static Vector3 SampleVectorChannels(
		Vector3 start,
		Vector3 end,
		Vector3 startTangents,
		Vector3 endTangents,
		TransformCurveChannel customChannels,
		TransformCurveChannel firstChannel,
		float progress,
		float duration,
		TrackInterpolation interpolation )
	{
		var legacy = interpolation == TrackInterpolation.Cubic
			? SmoothStep( progress )
			: progress;
		return new Vector3(
			SampleScalarChannel(
				start.x, end.x, startTangents.x, endTangents.x,
				(customChannels & firstChannel) != 0, progress, legacy, duration ),
			SampleScalarChannel(
				start.y, end.y, startTangents.y, endTangents.y,
				(customChannels & (TransformCurveChannel)((int)firstChannel << 1)) != 0,
				progress, legacy, duration ),
			SampleScalarChannel(
				start.z, end.z, startTangents.z, endTangents.z,
				(customChannels & (TransformCurveChannel)((int)firstChannel << 2)) != 0,
				progress, legacy, duration ) );
	}

	private static float SampleScalarChannel(
		float start,
		float end,
		float startTangent,
		float endTangent,
		bool custom,
		float progress,
		float legacyProgress,
		float duration ) =>
		custom
			? Hermite( start, end, startTangent * duration, endTangent * duration, progress )
			: start.LerpTo( end, legacyProgress );

	private static Rotation SampleRotationChannels(
		TransformKey current,
		TransformKey next,
		TransformCurveSpan span,
		float progress,
		float duration,
		TrackInterpolation interpolation )
	{
		var custom = span.CustomChannels & TransformCurveChannel.Rotation;
		var legacy = interpolation == TrackInterpolation.Cubic
			? SmoothStep( progress )
			: progress;
		if ( custom == TransformCurveChannel.None )
			return Rotation.Slerp( current.Rotation, next.Rotation, legacy );

		var startAngles = current.Rotation.Angles();
		var endAngles = next.Rotation.Angles();
		var start = new Vector3( startAngles.pitch, startAngles.yaw, startAngles.roll );
		var end = new Vector3(
			UnwrapDegrees( start.x, endAngles.pitch ),
			UnwrapDegrees( start.y, endAngles.yaw ),
			UnwrapDegrees( start.z, endAngles.roll ) );
		var legacyRotation = Rotation.Slerp( current.Rotation, next.Rotation, legacy );
		var legacyAngles = legacyRotation.Angles();
		var legacyValues = new Vector3(
			UnwrapDegrees( start.x, legacyAngles.pitch ),
			UnwrapDegrees( start.y, legacyAngles.yaw ),
			UnwrapDegrees( start.z, legacyAngles.roll ) );
		var sampled = new Vector3(
			(custom & TransformCurveChannel.RotationX) != 0
				? Hermite(
					start.x,
					end.x,
					current.CurveTangents.RotationOut.x * duration,
					next.CurveTangents.RotationIn.x * duration,
					progress )
				: legacyValues.x,
			(custom & TransformCurveChannel.RotationY) != 0
				? Hermite(
					start.y,
					end.y,
					current.CurveTangents.RotationOut.y * duration,
					next.CurveTangents.RotationIn.y * duration,
					progress )
				: legacyValues.y,
			(custom & TransformCurveChannel.RotationZ) != 0
				? Hermite(
					start.z,
					end.z,
					current.CurveTangents.RotationOut.z * duration,
					next.CurveTangents.RotationIn.z * duration,
					progress )
				: legacyValues.z );
		return Rotation.From( new Angles( sampled.x, sampled.y, sampled.z ) ).Normal;
	}

	private static float Hermite(
		float start,
		float end,
		float startTangent,
		float endTangent,
		float amount )
	{
		var amount2 = amount * amount;
		var amount3 = amount2 * amount;
		return (2 * amount3 - 3 * amount2 + 1) * start
			+ (amount3 - 2 * amount2 + amount) * startTangent
			+ (-2 * amount3 + 3 * amount2) * end
			+ (amount3 - amount2) * endTangent;
	}

	private static float SmoothStep( float amount ) =>
		amount * amount * (3.0f - 2.0f * amount);

	private static float UnwrapDegrees( float reference, float value )
	{
		var difference = (value - reference) % 360.0f;
		if ( difference > 180 )
			difference -= 360;
		else if ( difference < -180 )
			difference += 360;
		return reference + difference;
	}
}

public static class ClipConstraintEvaluator
{
	public static Transform Apply(
		Transform source,
		Transform target,
		TimedConstraint constraint,
		float time,
		Transform maintainedOffset )
	{
		if ( time < constraint.StartTime || time > constraint.EndTime || constraint.Weight <= 0 )
			return source;

		var desired = constraint.MaintainOffset
			? new Transform(
				target.PointToWorld( maintainedOffset.Position ),
				target.Rotation * maintainedOffset.Rotation,
				target.Scale * maintainedOffset.Scale )
			: target;

		var weight = Math.Clamp( constraint.Weight, 0.0f, 1.0f );
		return new Transform(
			Vector3.Lerp( source.Position, desired.Position, weight ),
			Rotation.Slerp( source.Rotation, desired.Rotation, weight ),
			Vector3.Lerp( source.Scale, desired.Scale, weight ) );
	}
}
sonac.sbox-animator / Runtime/WeaponAnimationDocument.cs
Game library
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using Sandbox;

namespace SboxWeaponAnimator;

public enum WeaponAnimatorStage
{
	Calibrate = 1,
	Animate = 2
}

public enum WeaponBoneClassification
{
	WeaponRoot,
	Animatable,
	Structural,
	Ignored
}

public enum WeaponBoneInclusion
{
	Included,
	StructuralBridge,
	Excluded
}

public enum MeasurementUnit
{
	Inches,
	Centimetres
}

public enum WeaponUpAxis
{
	PositiveZ,
	NegativeZ,
	PositiveY,
	NegativeY
}

public enum ClipReadiness
{
	NotStarted,
	Draft,
	Ready,
	Warning
}

public enum WeaponClipRole
{
	Custom,
	Idle,
	Deploy,
	Fire,
	FireDry,
	Reload,
	ReloadEmpty,
	Holster,
	Inspect,
	Sprint,
	Jump,
	Lower,
	Ironsights,
	GrabStance,
	GrabGestureOne,
	GrabGestureTwo,
	GrabGestureThree,
	GrabGestureFour,
	ReloadEnter,
	FirstShell,
	InsertShell,
	ReloadExit
}

public enum TrackInterpolation
{
	Stepped,
	Linear,
	Cubic
}

public enum CurveEditorMode
{
	Speed,
	Channels
}

[Flags]
public enum TransformCurveChannel
{
	None = 0,
	PositionX = 1 << 0,
	PositionY = 1 << 1,
	PositionZ = 1 << 2,
	RotationX = 1 << 3,
	RotationY = 1 << 4,
	RotationZ = 1 << 5,
	ScaleX = 1 << 6,
	ScaleY = 1 << 7,
	ScaleZ = 1 << 8,
	Position = PositionX | PositionY | PositionZ,
	Rotation = RotationX | RotationY | RotationZ,
	Scale = ScaleX | ScaleY | ScaleZ,
	All = Position | Rotation | Scale
}

public enum CurveHandleMode
{
	Aligned,
	Free
}

public enum AnimationTagKind
{
	Point,
	Range
}

public enum ReloadProfile
{
	Magazine,
	Incremental
}

public enum GripConfiguration
{
	OneHanded,
	TwoHanded
}

public enum AnchorKind
{
	Grip,
	RearBore,
	FrontBore,
	Muzzle,
	Eject,
	Custom
}

public enum RigControlKind
{
	Arm,
	Weapon,
	Camera
}

public enum VisibilityRenderMode
{
	BoneBranch,
	BodyGroup
}

public enum WeaponTextureChannel
{
	BaseColor,
	Normal,
	Roughness,
	Metalness,
	AmbientOcclusion,
	PackedOrm
}

public sealed class WeaponAnimationDocument
{
	public const int CurrentSchemaVersion = 4;

	public int SchemaVersion { get; set; } = CurrentSchemaVersion;
	public Guid DocumentId { get; set; } = Guid.NewGuid();
	public string Name { get; set; } = "New Weapon";
	public WeaponAnimatorStage ActiveStage { get; set; } = WeaponAnimatorStage.Calibrate;
	public SourceModelSettings Source { get; set; } = new();
	public WeaponRigDefinition Rig { get; set; } = new();
	public WeaponCalibration Calibration { get; set; } = new();
	public ArmBindingDefinition Binding { get; set; } = new();
	public List<WeaponAnimationClip> Clips { get; set; } = [];
	public AnimGraphSettings Graph { get; set; } = new();
	public OutputSettings Output { get; set; } = new();
	public WorkspaceState Workspace { get; set; } = new();

	// Ownership is persisted beside generated assets. Keeping file-like strings out of the
	// GameResource prevents the asset compiler from treating manifest entries as dependencies.
	[JsonIgnore]
	public GenerationManifest Manifest { get; set; } = new();

	public static WeaponAnimationDocument CreateDefault( string name = "New Weapon" )
	{
		var document = new WeaponAnimationDocument
		{
			Name = name,
			Output = new OutputSettings
			{
				AssetName = Slugify( name )
			}
		};

		document.Clips = StandardClips()
			.Select( role => WeaponAnimationClip.Create( role ) )
			.ToList();

		document.Workspace.SelectedClipId = document.Clips
			.First( x => x.Role == WeaponClipRole.Idle ).Id;

		return document;
	}

	public WeaponAnimationClip? GetSelectedClip()
	{
		return Clips.FirstOrDefault( x => x.Id == Workspace.SelectedClipId )
			?? Clips.FirstOrDefault();
	}

	public WeaponAnimationClip EnsureClip( WeaponClipRole role )
	{
		var clip = Clips.FirstOrDefault( x => x.Role == role );
		if ( clip is not null )
			return clip;

		clip = WeaponAnimationClip.Create( role );
		Clips.Add( clip );
		return clip;
	}

	public static IReadOnlyList<WeaponClipRole> StandardClips() =>
	[
		WeaponClipRole.Idle,
		WeaponClipRole.Deploy,
		WeaponClipRole.Fire,
		WeaponClipRole.FireDry,
		WeaponClipRole.Reload,
		WeaponClipRole.ReloadEmpty,
		WeaponClipRole.Holster,
		WeaponClipRole.Inspect,
		WeaponClipRole.Sprint,
		WeaponClipRole.Jump,
		WeaponClipRole.Lower,
		WeaponClipRole.Ironsights,
		WeaponClipRole.GrabStance,
		WeaponClipRole.GrabGestureOne,
		WeaponClipRole.GrabGestureTwo,
		WeaponClipRole.GrabGestureThree,
		WeaponClipRole.GrabGestureFour,
		WeaponClipRole.ReloadEnter,
		WeaponClipRole.FirstShell,
		WeaponClipRole.InsertShell,
		WeaponClipRole.ReloadExit
	];

	public static string Slugify( string value )
	{
		if ( string.IsNullOrWhiteSpace( value ) )
			return "weapon";

		var chars = value.Trim().ToLowerInvariant()
			.Select( c => char.IsLetterOrDigit( c ) ? c : '_' )
			.ToArray();

		return string.Join( "_", new string( chars )
			.Split( '_', StringSplitOptions.RemoveEmptyEntries ) );
	}
}

public sealed class SourceModelSettings
{
	public string OriginalSourcePath { get; set; } = "";
	public string SourcePath { get; set; } = "";
	public string CompiledModelPath { get; set; } = "";
	public string PreviewHostPath { get; set; } = "";
	public string SourceHash { get; set; } = "";
	public string SourceRootBoneName { get; set; } = "";
	public Vector3 OriginalModelDimensions { get; set; }
	public bool NeedsModelDocWrapper { get; set; }
	public bool Compiled { get; set; }
	public bool PreviewHostCompiled { get; set; }
	public DateTime LastImportedUtc { get; set; }
	public List<SourceMaterialBinding> Materials { get; set; } = [];
}

public sealed class SourceMaterialBinding
{
	// Stored without a resource extension so the .wepanim compiler does not treat an
	// imported FBX slot label as a project asset dependency.
	public string SourceMaterialPath { get; set; } = "";
	public string Name { get; set; } = "";
	public string OutputName { get; set; } = "";

	[JsonIgnore]
	public string PreviewMaterialPath { get; set; } = "";
	public List<SourceTextureMap> Textures { get; set; } = [];

	public SourceTextureMap? FindTexture( WeaponTextureChannel channel ) =>
		Textures.FirstOrDefault( texture => texture.Channel == channel );

	public bool HasUsableTextures =>
		Textures.Any( texture => texture.Channel != WeaponTextureChannel.PackedOrm
			&& !string.IsNullOrWhiteSpace( texture.AssetPath ) );
}

public sealed class SourceTextureMap
{
	public WeaponTextureChannel Channel { get; set; }

	[JsonIgnore]
	public string OriginalPath { get; set; } = "";
	public string AssetPath { get; set; } = "";
	public string Sha256 { get; set; } = "";
}

public sealed class WeaponRigDefinition
{
	public string RootBone { get; set; } = "";
	public string SourceSkeletonRootId { get; set; } = "";
	public string WeaponSubtreeRootId { get; set; } = "";
	public List<WeaponBoneDefinition> Bones { get; set; } = [];
	public List<WeaponVisibilityPart> VisibilityParts { get; set; } = [];
	public List<RigAuditIssue> AuditIssues { get; set; } = [];
	public string ProfileHash { get; set; } = "";
	public bool ReviewRequired { get; set; }
	public bool FilteredPreviewConfirmed { get; set; }

	public WeaponBoneDefinition? FindBone( string idOrName ) =>
		Bones.FirstOrDefault( x =>
			string.Equals( x.Id, idOrName, StringComparison.OrdinalIgnoreCase )
			|| string.Equals( x.Name, idOrName, StringComparison.OrdinalIgnoreCase ) );

	public IEnumerable<WeaponBoneDefinition> RetainedBones() =>
		Bones.Where( x => x.Inclusion != WeaponBoneInclusion.Excluded
			&& x.Classification != WeaponBoneClassification.Ignored );
}

public sealed class WeaponVisibilityPart
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public string Name { get; set; } = "Visible Part";
	public string BoneId { get; set; } = "";
	public string BoneName { get; set; } = "";
	public bool DefaultVisible { get; set; } = true;
	public VisibilityRenderMode RenderMode { get; set; } = VisibilityRenderMode.BoneBranch;
	public string BodyGroupName { get; set; } = "";
	public int VisibleBodyGroupValue { get; set; } = 1;
	public int HiddenBodyGroupValue { get; set; }
}

public sealed class WeaponBoneDefinition
{
	public string Id { get; set; } = "";
	public string ParentId { get; set; } = "";
	public string HierarchyPath { get; set; } = "";
	public string Name { get; set; } = "";
	public string ParentName { get; set; } = "";
	public string OriginalName { get; set; } = "";
	public string OriginalParentName { get; set; } = "";
	public WeaponBoneClassification Classification { get; set; } = WeaponBoneClassification.Animatable;
	public WeaponBoneInclusion Inclusion { get; set; } = WeaponBoneInclusion.Included;

	// BindTransform is retained for loading version 2 projects.
	public Transform BindTransform { get; set; } = Transform.Zero;
	public Transform BindModelTransform { get; set; } = Transform.Zero;
	public Transform BindLocalTransform { get; set; } = Transform.Zero;
	public bool HasSkinInfluence { get; set; }
}

public sealed class RigAuditIssue
{
	public string Code { get; set; } = "";
	public string Message { get; set; } = "";
	public ValidationSeverity Severity { get; set; } = ValidationSeverity.Warning;
	public string BoneName { get; set; } = "";
}

public sealed class WeaponCalibration
{
	public float UniformScale { get; set; } = 1.0f;
	public Transform PhysicalTransform { get; set; } = Transform.Zero;
	public Transform FramingTransform { get; set; } = Transform.Zero;
	public ScaleMeasurement Measurement { get; set; } = new();
	public List<WeaponAnchor> Anchors { get; set; } = [];
	public WeaponUpAxis UpAxis { get; set; } = WeaponUpAxis.PositiveZ;
	public float HorizontalFov { get; set; } = 80.0f;
	public string AspectGuide { get; set; } = "16:9";
	public bool ShowSafeArea { get; set; } = true;
	public bool ShowCrosshair { get; set; } = true;
	public bool Confirmed { get; set; }
	public int Revision { get; set; }
	public CalibrationSnapshot? Snapshot { get; set; }

	/// <summary>
	/// Resolves the single anchor of a fixed kind. Custom anchors are identified by id instead,
	/// because a weapon may carry several of them.
	/// </summary>
	public WeaponAnchor? GetAnchor( AnchorKind kind ) =>
		Anchors.FirstOrDefault( x => x.Kind == kind );

	public WeaponAnchor? FindAnchor( Guid id ) =>
		Anchors.FirstOrDefault( x => x.Id == id );

	public IEnumerable<WeaponAnchor> CustomAnchors() =>
		Anchors.Where( x => x.Kind == AnchorKind.Custom );

	public void SetAnchor( WeaponAnchor anchor )
	{
		var existing = anchor.Kind == AnchorKind.Custom
			? FindAnchor( anchor.Id )
			: GetAnchor( anchor.Kind );
		if ( existing is null )
			Anchors.Add( anchor );
		else
		{
			existing.Name = anchor.Name;
			existing.BoneName = anchor.BoneName;
			existing.LocalPosition = anchor.LocalPosition;
			existing.LocalRotation = anchor.LocalRotation;
		}
	}
}

public sealed class ScaleMeasurement
{
	public bool HasFirstPoint { get; set; }
	public bool HasSecondPoint { get; set; }
	public Vector3 FirstPoint { get; set; }
	public Vector3 SecondPoint { get; set; }
	public string FirstBone { get; set; } = "";
	public string SecondBone { get; set; } = "";
	public float KnownDistance { get; set; }
	public MeasurementUnit Unit { get; set; } = MeasurementUnit.Inches;
	public float PreviewScale { get; set; } = 1.0f;
	public bool HasPendingScale { get; set; }
	public Vector3 OriginalDimensions { get; set; }
	public Vector3 ResultingDimensions { get; set; }
}

public sealed class WeaponAnchor
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public string Name { get; set; } = "";

	/// <summary>
	/// Attachment name emitted for custom anchors. Stored rather than derived so renaming the
	/// anchor cannot silently rename an attachment that game code already references.
	/// </summary>
	public string GeneratedAttachmentName { get; set; } = "";
	public AnchorKind Kind { get; set; }
	public string BoneName { get; set; } = "";
	public Vector3 LocalPosition { get; set; }
	public Rotation LocalRotation { get; set; } = Rotation.Identity;
}

public sealed class CalibrationSnapshot
{
	public int Revision { get; set; }
	public string SourceHash { get; set; } = "";
	public string RigHash { get; set; } = "";
	public float UniformScale { get; set; } = 1.0f;
	public Transform PhysicalTransform { get; set; } = Transform.Zero;
	public Transform FramingTransform { get; set; } = Transform.Zero;
	public List<WeaponAnchor> Anchors { get; set; } = [];
	public DateTime ConfirmedUtc { get; set; }
}

public sealed class ArmBindingDefinition
{
	public string Profile { get; set; } = "FacepunchHumanV1";
	public string ArmsModel { get; set; } = "models/first_person/v_first_person_arms_human.vmdl";
	public GripConfiguration Configuration { get; set; } = GripConfiguration.TwoHanded;
	public RigTarget PrimaryHand { get; set; } = RigTarget.Create( "Primary Hand", true );
	public RigTarget SupportHand { get; set; } = RigTarget.Create( "Support Hand", false );
	public RigTarget PrimaryElbowPole { get; set; } = RigTarget.CreatePole( "Primary Elbow" );
	public RigTarget SupportElbowPole { get; set; } = RigTarget.CreatePole( "Support Elbow" );
	public List<GripPose> GripPoses { get; set; } = [];
	public Guid DefaultGripPoseId { get; set; }
	public bool ChecklistDismissed { get; set; }
	public List<string> CompletedChecklistItems { get; set; } = [];
}

public sealed class RigTarget
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public string Name { get; set; } = "";
	public RigControlKind Kind { get; set; } = RigControlKind.Arm;
	public Transform Transform { get; set; } = Transform.Zero;
	public string AttachedBone { get; set; } = "";
	public bool IsPrimary { get; set; }
	public bool IsBound { get; set; }
	public bool Reachable { get; set; } = true;

	public static RigTarget Create( string name, bool primary ) => new()
	{
		Name = name,
		IsPrimary = primary,
		Transform = new Transform( new Vector3( 12, primary ? -3 : 3, -2 ) )
	};

	public static RigTarget CreatePole( string name ) => new()
	{
		Name = name,
		Transform = new Transform( new Vector3( 5, name.Contains( "Primary" ) ? -12 : 12, -5 ) )
	};
}

public sealed class GripPose
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public string Name { get; set; } = "Default Grip";
	public List<BonePose> Bones { get; set; } = [];
}

public sealed class BonePose
{
	public string BoneName { get; set; } = "";
	public Transform LocalTransform { get; set; } = Transform.Zero;
}

public sealed class WeaponAnimationClip
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public string Name { get; set; } = "Custom";
	public WeaponClipRole Role { get; set; }
	// Generated Idle bind poses can follow calibration changes until deliberately authored.
	public bool IsBindPoseSeed { get; set; }
	public ClipReadiness Readiness { get; set; } = ClipReadiness.NotStarted;
	public float Duration { get; set; } = 1.0f;
	public float SampleRate { get; set; } = 30.0f;
	public bool AllowSubframeKeys { get; set; }
	public bool Loop { get; set; }
	public string GeneratedSequenceName { get; set; } = "";
	public List<TransformTrack> Tracks { get; set; } = [];
	public List<VisibilityTrack> VisibilityTracks { get; set; } = [];
	public List<TimedConstraint> Constraints { get; set; } = [];
	public List<AnimationTag> Tags { get; set; } = [];
	public List<ClipParameterEvent> ParameterEvents { get; set; } = [];
	public string ImportedSequence { get; set; } = "";

	public static WeaponAnimationClip Create( WeaponClipRole role ) => new()
	{
		Name = WeaponAnimationNames.DisplayName( role ),
		Role = role,
		Loop = role is WeaponClipRole.Idle or WeaponClipRole.Sprint
	};

	public TransformTrack EnsureTrack( string target )
	{
		var track = Tracks.FirstOrDefault( x => x.Target == target );
		if ( track is not null )
			return track;

		track = new TransformTrack { Target = target };
		Tracks.Add( track );
		return track;
	}

	public VisibilityTrack EnsureVisibilityTrack( Guid partId )
	{
		var track = VisibilityTracks.FirstOrDefault( x => x.PartId == partId );
		if ( track is not null )
			return track;

		track = new VisibilityTrack { PartId = partId };
		VisibilityTracks.Add( track );
		return track;
	}
}

public sealed class VisibilityTrack
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public Guid PartId { get; set; }
	public List<VisibilityKey> Keys { get; set; } = [];
	public bool Muted { get; set; }
}

public sealed class VisibilityKey
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public float Time { get; set; }
	public bool Visible { get; set; } = true;
}

public sealed class TransformTrack
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public string Target { get; set; } = "";
	public RigControlKind Kind { get; set; } = RigControlKind.Weapon;
	public TrackInterpolation Interpolation { get; set; } = TrackInterpolation.Cubic;
	public List<TransformKey> Keys { get; set; } = [];
	public List<TransformCurveSpan> CurveSpans { get; set; } = [];
	public bool Muted { get; set; }

	public TransformCurveSpan? FindCurveSpan( Guid startKeyId, Guid endKeyId ) =>
		CurveSpans.FirstOrDefault( x =>
			x.StartKeyId == startKeyId && x.EndKeyId == endKeyId );

	public TransformCurveSpan EnsureCurveSpan( Guid startKeyId, Guid endKeyId )
	{
		var span = FindCurveSpan( startKeyId, endKeyId );
		if ( span is not null )
			return span;

		span = new TransformCurveSpan
		{
			StartKeyId = startKeyId,
			EndKeyId = endKeyId
		};
		CurveSpans.Add( span );
		return span;
	}
}

public sealed class TransformKey
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public float Time { get; set; }
	public Vector3 Position { get; set; }
	public Rotation Rotation { get; set; } = Rotation.Identity;
	public Vector3 Scale { get; set; } = Vector3.One;
	// Retained for schema-v3 compatibility; migrated into CurveTangents.
	public Vector3 InTangent { get; set; }
	public Vector3 OutTangent { get; set; }
	public TransformCurveTangents CurveTangents { get; set; } = new();
}

public sealed class TransformCurveSpan
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public Guid StartKeyId { get; set; }
	public Guid EndKeyId { get; set; }
	public bool HasSpeedCurve { get; set; }
	public MotionRateCurve Speed { get; set; } = new();
	public bool HasInterpolationOverride { get; set; }
	public TrackInterpolation Interpolation { get; set; } = TrackInterpolation.Linear;
	public TransformCurveChannel CustomChannels { get; set; }
}

public sealed class MotionRateCurve
{
	public float StartRate { get; set; } = 1.0f;
	public float EndRate { get; set; } = 1.0f;
	public float StartSlope { get; set; }
	public float EndSlope { get; set; }
	public CurveHandleMode StartHandleMode { get; set; } = CurveHandleMode.Aligned;
	public CurveHandleMode EndHandleMode { get; set; } = CurveHandleMode.Aligned;
}

public sealed class TransformCurveTangents
{
	public Vector3 PositionIn { get; set; }
	public Vector3 PositionOut { get; set; }
	public Vector3 RotationIn { get; set; }
	public Vector3 RotationOut { get; set; }
	public Vector3 ScaleIn { get; set; }
	public Vector3 ScaleOut { get; set; }
	public TransformCurveChannel FreeHandles { get; set; }
}

public sealed class TimedConstraint
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public string SourceControl { get; set; } = "";
	public string TargetBone { get; set; } = "";
	public float StartTime { get; set; }
	public float EndTime { get; set; } = 1.0f;
	public float Weight { get; set; } = 1.0f;
	public bool MaintainOffset { get; set; } = true;
}

public sealed class AnimationTag
{
	public Guid Id { get; set; } = Guid.NewGuid();
	public string Name { get; set; } = "";
	public AnimationTagKind Kind { get; set; }
	public float StartTime { get; set; }
	public float EndTime { get; set; }
}

public sealed class ClipParameterEvent
{
	public string Name { get; set; } = "";
	public float Time { get; set; }
	public float Value { get; set; }
}

public sealed class AnimGraphSettings
{
	public bool GenerateGraph { get; set; } = true;
	public string ParameterProfile { get; set; } = "FacepunchHumanV1";
	public ReloadProfile ReloadProfile { get; set; } = ReloadProfile.Magazine;
	public bool FirearmProfile { get; set; } = true;
	public Dictionary<string, float> PreviewFloats { get; set; } = [];
	public Dictionary<string, bool> PreviewBools { get; set; } = [];
}

public sealed class OutputSettings
{
	public string AssetName { get; set; } = "weapon";
	public string OutputFolder { get; set; } = "";
	public bool GeneratePrefab { get; set; } = true;
	public bool GenerateGraph { get; set; } = true;
	public bool IncludeDebugSkeleton { get; set; }

	public string GetDefaultRelativeFolder()
	{
		var slug = WeaponAnimationDocument.Slugify( AssetName );
		return $"weapons/{slug}/viewmodel";
	}
}

public sealed class WorkspaceState
{
	public Guid SelectedClipId { get; set; }
	public float TimelineTime { get; set; }
	public string SelectedBone { get; set; } = "";
	public string SelectedControl { get; set; } = "";
	public string ConstraintTargetBone { get; set; } = "";
	public bool FirstPersonPreview { get; set; }
	public bool ShowGuides { get; set; }
	public bool ShowSkeleton { get; set; } = true;
	public bool XRaySkeleton { get; set; } = true;
	public bool BoneOcclusionEnabled { get; set; } = true;
	public bool ShowIkBones { get; set; }
	public bool ShowOnionSkins { get; set; }
	public float GridOpacity { get; set; } = 0.10f;
	public float GridLineThickness { get; set; } = 0.65f;
	public bool RimLightEnabled { get; set; } = true;
	public float RimLightIntensity { get; set; } = 4.0f;
	public bool AutoKey { get; set; } = true;
	public bool LocalGizmos { get; set; } = true;
	public bool SnapPosition { get; set; } = true;
	public bool SnapRotation { get; set; } = true;
	public float RotationSnapDegrees { get; set; } = 15.0f;
	public bool CurveEditorVisible { get; set; }
	public List<WorkingPoseOverride> WorkingPoseOverrides { get; set; } = [];
	public List<TimelineViewState> TimelineViews { get; set; } = [];
	public List<CurveViewState> CurveViews { get; set; } = [];
	public Vector3 CameraFocus { get; set; }
	public Angles CameraAngles { get; set; } = new( 12, 180, 0 );
	public float CameraDistance { get; set; } = 48.0f;
	public bool FreeLookCamera { get; set; }
	public Vector3 CameraPosition { get; set; }
	public float CameraMoveSpeed { get; set; } = 1.0f;
	public bool FullBrightViewport { get; set; }
	public string CalibrationSplitterState { get; set; } = "";
	public string AnimationSplitterState { get; set; } = "";
	public string CalibrationVerticalSplitterState { get; set; } = "";
	public string AnimationVerticalSplitterState { get; set; } = "";
	public string AnimationTimelineSplitterState { get; set; } = "";
	public string AnimationRightSplitterState { get; set; } = "";
	public string AnimationMainSplitterState { get; set; } = "";
	public string AnimationOuterSplitterState { get; set; } = "";

	public TimelineViewState? GetTimelineView( Guid clipId ) =>
		TimelineViews.FirstOrDefault( x => x.ClipId == clipId );

	public TimelineViewState EnsureTimelineView( Guid clipId, float duration )
	{
		var existing = GetTimelineView( clipId );
		if ( existing is not null )
			return existing;

		existing = new TimelineViewState
		{
			ClipId = clipId,
			VisibleEnd = MathF.Max( duration, 0 )
		};
		TimelineViews.Add( existing );
		return existing;
	}

	public CurveViewState? GetCurveView( Guid clipId ) =>
		CurveViews.FirstOrDefault( x => x.ClipId == clipId );

	public CurveViewState EnsureCurveView( Guid clipId )
	{
		var existing = GetCurveView( clipId );
		if ( existing is not null )
			return existing;

		existing = new CurveViewState { ClipId = clipId };
		CurveViews.Add( existing );
		return existing;
	}

	public WorkingPoseOverride? GetWorkingPose( Guid clipId, string target ) =>
		WorkingPoseOverrides.FirstOrDefault( x =>
			x.ClipId == clipId
			&& x.Target.Equals( target, StringComparison.OrdinalIgnoreCase ) );

	public void SetWorkingPose(
		Guid clipId,
		string target,
		RigControlKind kind,
		Transform transform )
	{
		var existing = GetWorkingPose( clipId, target );
		if ( existing is null )
		{
			WorkingPoseOverrides.Add( new WorkingPoseOverride
			{
				ClipId = clipId,
				Target = target,
				Kind = kind,
				Transform = transform
			} );
			return;
		}

		existing.Kind = kind;
		existing.Transform = transform;
	}

	public bool RemoveWorkingPose( Guid clipId, string target ) =>
		WorkingPoseOverrides.RemoveAll( x =>
			x.ClipId == clipId
			&& x.Target.Equals( target, StringComparison.OrdinalIgnoreCase ) ) > 0;

	public void ClearWorkingPoses( Guid clipId ) =>
		WorkingPoseOverrides.RemoveAll( x => x.ClipId == clipId );
}

public sealed class TimelineViewState
{
	public Guid ClipId { get; set; }
	public float VisibleStart { get; set; }
	public float VisibleEnd { get; set; }
	public float VerticalScroll { get; set; }
}

public sealed class CurveViewState
{
	public Guid ClipId { get; set; }
	public Guid SelectedTrackId { get; set; }
	public CurveEditorMode Mode { get; set; }
	public TransformCurveChannel VisibleChannels { get; set; }
	public string Search { get; set; } = "";
	public float TrackScroll { get; set; }
	public bool HasVerticalRange { get; set; }
	public float VerticalMinimum { get; set; }
	public float VerticalMaximum { get; set; } = 2.0f;
}

public sealed class WorkingPoseOverride
{
	public Guid ClipId { get; set; }
	public string Target { get; set; } = "";
	public RigControlKind Kind { get; set; }
	public Transform Transform { get; set; } = Transform.Zero;
}

public sealed class GenerationManifest
{
	public string GeneratorVersion { get; set; } = "";
	public DateTime GeneratedUtc { get; set; }
	public string InputHash { get; set; } = "";
	public List<GeneratedFileRecord> Files { get; set; } = [];
	public List<GenerationDiagnostic> Diagnostics { get; set; } = [];
}

public sealed class GeneratedFileRecord
{
	public string RelativePath { get; set; } = "";
	public string Sha256 { get; set; } = "";
	public string Kind { get; set; } = "";
}

public sealed class GenerationDiagnostic
{
	public ValidationSeverity Severity { get; set; }
	public string Code { get; set; } = "";
	public string Message { get; set; } = "";
	public string AssetPath { get; set; } = "";
}

public static class WeaponAnimationNames
{
	public static string DisplayName( WeaponClipRole role ) => role switch
	{
		WeaponClipRole.FireDry => "Fire Dry",
		WeaponClipRole.ReloadEmpty => "Reload Empty",
		WeaponClipRole.GrabStance => "Grab Stance",
		WeaponClipRole.GrabGestureOne => "Grab Gesture 1",
		WeaponClipRole.GrabGestureTwo => "Grab Gesture 2",
		WeaponClipRole.GrabGestureThree => "Grab Gesture 3",
		WeaponClipRole.GrabGestureFour => "Grab Gesture 4",
		WeaponClipRole.ReloadEnter => "Reload Enter",
		WeaponClipRole.FirstShell => "First Shell",
		WeaponClipRole.InsertShell => "Insert Shell",
		WeaponClipRole.ReloadExit => "Reload Exit",
		_ => role.ToString()
	};

	public static string SequenceName( WeaponClipRole role ) =>
		WeaponAnimationDocument.Slugify( DisplayName( role ) );

	public static string SequenceName( WeaponAnimationClip clip ) =>
		clip.Role == WeaponClipRole.Custom
			? !string.IsNullOrWhiteSpace( clip.GeneratedSequenceName )
				? clip.GeneratedSequenceName
				: ShortCustomSequenceName(
					WeaponAnimationDocument.Slugify( clip.Name ),
					clip.Id )
			: SequenceName( clip.Role );

	public static bool RepairCustomSequenceNames( WeaponAnimationDocument document )
	{
		var changed = false;
		var used = document.Clips
			.Where( clip => clip.Role != WeaponClipRole.Custom )
			.Select( clip => SequenceName( clip.Role ) )
			.ToHashSet( StringComparer.OrdinalIgnoreCase );
		foreach ( var clip in document.Clips.Where( clip =>
			clip.Role == WeaponClipRole.Custom ) )
		{
			var existing = string.IsNullOrWhiteSpace( clip.GeneratedSequenceName )
				? ""
				: WeaponAnimationDocument.Slugify( clip.GeneratedSequenceName );
			if ( !string.IsNullOrWhiteSpace( existing ) && used.Add( existing ) )
			{
				if ( clip.GeneratedSequenceName != existing )
				{
					clip.GeneratedSequenceName = existing;
					changed = true;
				}
				continue;
			}

			var stem = WeaponAnimationDocument.Slugify( clip.Name );
			if ( string.IsNullOrWhiteSpace( stem ) )
				stem = "custom";
			var candidate = stem;
			if ( !used.Add( candidate ) )
			{
				var id = clip.Id.ToString( "N" );
				var assigned = false;
				for ( var suffixLength = 8;
					suffixLength <= id.Length;
					suffixLength += 4 )
				{
					candidate = $"{stem}_{id[..suffixLength]}";
					if ( !used.Add( candidate ) )
						continue;
					assigned = true;
					break;
				}

				for ( var collision = 2; !assigned; collision++ )
				{
					candidate = $"{stem}_{id}_{collision}";
					assigned = used.Add( candidate );
				}
			}
			if ( clip.GeneratedSequenceName == candidate )
				continue;
			clip.GeneratedSequenceName = candidate;
			changed = true;
		}
		return changed;
	}

	private static string ShortCustomSequenceName( string stem, Guid id ) =>
		$"{stem}_{id:N}"[..(stem.Length + 9)];

	/// <summary>
	/// Attachment names reserved by the fixed anchor kinds that reach generation. The calibration-only
	/// kinds (grip, bore markers) are never exported, so they reserve nothing.
	/// </summary>
	private static readonly string[] ReservedAttachmentNames = ["muzzle", "eject"];

	public static string AttachmentName( WeaponAnchor anchor ) => anchor.Kind switch
	{
		AnchorKind.Muzzle => "muzzle",
		AnchorKind.Eject => "eject",
		_ => !string.IsNullOrWhiteSpace( anchor.GeneratedAttachmentName )
			? anchor.GeneratedAttachmentName
			: WeaponAnimationDocument.Slugify( anchor.Name )
	};

	/// <summary>
	/// Assigns each custom anchor a stable, unique attachment name. Mirrors
	/// <see cref="RepairCustomSequenceNames"/>: an existing stored name is kept whenever it is still
	/// unique, so generated output stays stable across renames.
	/// </summary>
	public static bool RepairCustomAnchorNames( WeaponAnimationDocument document )
	{
		var changed = false;
		var used = ReservedAttachmentNames.ToHashSet( StringComparer.OrdinalIgnoreCase );
		foreach ( var anchor in document.Calibration.CustomAnchors() )
		{
			var existing = string.IsNullOrWhiteSpace( anchor.GeneratedAttachmentName )
				? ""
				: WeaponAnimationDocument.Slugify( anchor.GeneratedAttachmentName );
			if ( !string.IsNullOrWhiteSpace( existing ) && used.Add( existing ) )
			{
				if ( anchor.GeneratedAttachmentName != existing )
				{
					anchor.GeneratedAttachmentName = existing;
					changed = true;
				}
				continue;
			}

			var stem = WeaponAnimationDocument.Slugify( anchor.Name );
			if ( string.IsNullOrWhiteSpace( stem ) )
				stem = "anchor";
			var candidate = stem;
			if ( !used.Add( candidate ) )
			{
				var id = anchor.Id.ToString( "N" );
				var assigned = false;
				for ( var suffixLength = 8; suffixLength <= id.Length; suffixLength += 4 )
				{
					candidate = $"{stem}_{id[..suffixLength]}";
					if ( !used.Add( candidate ) )
						continue;
					assigned = true;
					break;
				}

				for ( var collision = 2; !assigned; collision++ )
				{
					candidate = $"{stem}_{id}_{collision}";
					assigned = used.Add( candidate );
				}
			}
			if ( anchor.GeneratedAttachmentName == candidate )
				continue;
			anchor.GeneratedAttachmentName = candidate;
			changed = true;
		}
		return changed;
	}
}
sonac.sbox-animator / Editor/Services/PreviewHostBuilder.cs
Editor library
#nullable enable annotations

using System;
using System.IO;
using System.Linq;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class PreviewHostResult
{
	public bool Success { get; init; }
	public string Message { get; init; } = "";
	public string ModelPath { get; init; } = "";
}

public static class PreviewHostBuilder
{
	public static PreviewHostResult Build( WeaponAnimationDocument document )
	{
		try
		{
			document.Rig.AuditIssues.RemoveAll( x =>
				x.Code is "arm_bone_collision" or "bind_pose_mismatch" );
			var collisions = HostSkeletonBuilder.FindArmBoneCollisions( document );
			foreach ( var collision in collisions )
			{
				document.Rig.AuditIssues.Add( new RigAuditIssue
				{
					Code = "arm_bone_collision",
					Message = $"Retained weapon bone '{collision}' conflicts with the Facepunch arm skeleton.",
					Severity = ValidationSeverity.Error,
					BoneName = collision
				} );
			}

			var parityIssues = HostSkeletonBuilder.ValidateBindParity( document );
			foreach ( var mismatch in parityIssues )
			{
				document.Rig.AuditIssues.Add( new RigAuditIssue
				{
					Code = "bind_pose_mismatch",
					Message =
						$"Bind pose mismatch for '{mismatch.BoneName}': "
						+ $"position {mismatch.PositionDelta:0.######}, "
						+ $"rotation {mismatch.RotationDelta:0.######}, "
						+ $"scale {mismatch.ScaleDelta:0.######}.",
					Severity = ValidationSeverity.Error,
					BoneName = mismatch.BoneName
				} );
			}

			if ( collisions.Count > 0 || parityIssues.Count > 0 )
			{
				document.Source.PreviewHostCompiled = false;
				var blockedDetail = collisions.Count > 0
					? $"Retained weapon bones collide with Facepunch bones: {string.Join( ", ", collisions )}."
					: document.Rig.AuditIssues.First( x => x.Code == "bind_pose_mismatch" ).Message;
				Log.Warning( $"[Weapon Animator] preview host blocked: {blockedDetail}" );
				return new PreviewHostResult
				{
					Success = false,
					Message = blockedDetail
				};
			}

			var cache = WeaponSourceImporter.GetPreviewCacheRoot( document.DocumentId );
			Directory.CreateDirectory( cache );
			var skeleton = HostSkeletonBuilder.Build( document );
			var dmxAbsolute = Path.Combine( cache, "animation_host_reference.dmx" );
			var vmdlAbsolute = Path.Combine( cache, "animation_host_preview.vmdl" );
			var dmxRelative = WeaponSourceImporter.RelativeAssetPath( dmxAbsolute );

			AtomicFile.WriteAllText( dmxAbsolute, DmxWriter.WriteReference( skeleton ) );
			AtomicFile.WriteAllText(
				vmdlAbsolute,
				ModelDocWriter.WriteHost(
					dmxRelative,
					[],
					"",
					skeleton.Bones.Select( bone => bone.Name ) ) );

			var asset = AssetSystem.RegisterFile( vmdlAbsolute );
			var compileReturned = asset?.Compile( true ) == true;
			var compiled = asset is not null
				&& asset.IsCompiled
				&& asset.IsCompiledAndUpToDate;
			var model = compiled ? asset!.LoadResource<Model>() : null;
			var success = compiled && model is not null && !model.IsError && model.BoneCount == skeleton.Bones.Count;
			var detail = DescribeResult(
				asset,
				compileReturned,
				compiled,
				model,
				skeleton.Bones.Count );

			document.Source.PreviewHostPath = asset?.Path ?? "";
			document.Source.PreviewHostCompiled = success;
			if ( !success )
				Log.Warning( $"[Weapon Animator] preview host verification failed: {detail}" );
			return new PreviewHostResult
			{
				Success = success,
				ModelPath = asset?.Path ?? "",
				Message = success
					? $"Preview host compiled with {skeleton.Bones.Count} bones."
					: $"Preview host verification failed: {detail}"
			};
		}
		catch ( Exception ex )
		{
			document.Source.PreviewHostCompiled = false;
			Log.Error( $"[Weapon Animator] preview host build failed: {ex}" );
			return new PreviewHostResult
			{
				Success = false,
				Message = ex.Message
			};
		}
	}

	private static string DescribeResult(
		Asset? asset,
		bool compileReturned,
		bool compiled,
		Model? model,
		int expectedBones )
	{
		if ( asset is null )
			return "the generated VMDL was not registered with the Asset System.";
		if ( !compiled )
		{
			return $"compile returned {compileReturned}, IsCompiled={asset.IsCompiled}, "
				+ $"IsCompiledAndUpToDate={asset.IsCompiledAndUpToDate}.";
		}
		if ( model is null )
			return "the compiled resource could not be loaded as a model.";
		if ( model.IsError )
			return "the compiled resource reloaded as the error model.";
		if ( model.BoneCount != expectedBones )
			return $"expected {expectedBones} bones but the compiled model exposes {model.BoneCount}.";
		return "the compiled resource did not pass validation.";
	}
}

public static class AtomicFile
{
	public static void WriteAllText( string path, string content )
	{
		var directory = Path.GetDirectoryName( path );
		if ( !string.IsNullOrWhiteSpace( directory ) )
			Directory.CreateDirectory( directory );

		var temporary = path + $".tmp.{Guid.NewGuid():N}";
		File.WriteAllText( temporary, content, new System.Text.UTF8Encoding( false ) );
		File.Move( temporary, path, true );
	}
}
sonac.sbox-animator / Editor/Widgets/AnimationWorkspaceRedesign.cs
Editor library
#nullable enable annotations

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

namespace SboxWeaponAnimator.Editor;

public sealed class RigBrowserPanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly ScrollArea _scroll;
	private readonly Widget _canvas;
	private readonly LineEdit _search;
	private readonly Dictionary<string, WeaponAnimatorButton> _itemButtons =
		new( StringComparer.OrdinalIgnoreCase );
	private readonly Dictionary<string, bool> _weaponItems =
		new( StringComparer.OrdinalIgnoreCase );
	private readonly Dictionary<string, bool> _expanded = new( StringComparer.OrdinalIgnoreCase )
	{
		["Controls"] = true,
		["Weapon"] = true,
		["Right arm"] = true,
		["Left arm"] = true,
		["Fingers"] = false,
		["Advanced"] = false
	};
	private string _filter = "";
	private bool _rebuilding;
	private bool _rebuildPending;
	private string _structureSignature = "";

	public RigBrowserPanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		Layout = Layout.Column();
		Layout.Margin = new Sandbox.UI.Margin( 8 );
		Layout.Spacing = 6;

		_search = new LineEdit( this )
		{
			PlaceholderText = "Search controls and bones…",
			FixedHeight = 28
		};
		_search.SetStyles( WeaponAnimatorTheme.InputStyle );
		_search.TextChanged += text =>
		{
			_filter = text.Trim();
			Rebuild();
		};
		Layout.Add( _search );

		_scroll = new ScrollArea( this );
		_canvas = new Widget( _scroll );
		_canvas.Layout = Layout.Column();
		_canvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();
		_canvas.Layout.Spacing = 2;
		_scroll.Canvas = _canvas;
		Layout.Add( _scroll, 1 );

		_controller.DocumentChanged += RefreshDocument;
		_controller.SelectionChanged += RefreshSelection;
		Rebuild();
	}

	public override void OnDestroyed()
	{
		_controller.DocumentChanged -= RefreshDocument;
		_controller.SelectionChanged -= RefreshSelection;
		base.OnDestroyed();
	}

	private void RefreshDocument()
	{
		var skeleton = HostSkeletonBuilder.BuildCached( _controller.Document );
		if ( _structureSignature != StructureSignature( skeleton ) )
		{
			Rebuild();
			return;
		}

		UpdateControlLabels();
		RefreshSelection();
	}

	private void Rebuild()
	{
		if ( _rebuilding )
		{
			_rebuildPending = true;
			return;
		}

		_rebuilding = true;
		try
		{
			do
			{
				_rebuildPending = false;
				_canvas.Layout.Clear( true );
				_itemButtons.Clear();
				_weaponItems.Clear();
				var skeleton = HostSkeletonBuilder.BuildCached( _controller.Document );
				_structureSignature = StructureSignature( skeleton );
				var groups = GroupBones( skeleton );
				AddControlGroup();
				foreach ( var name in new[] { "Weapon", "Right arm", "Left arm", "Fingers", "Advanced" } )
					AddBoneGroup( name, groups.GetValueOrDefault( name ) ?? [], skeleton );
				_canvas.Layout.AddStretchCell();
			}
			while ( _rebuildPending );
		}
		finally
		{
			_rebuilding = false;
		}

		RefreshSelection( reveal: false );
		UpdateControlLabels();
	}

	private void AddControlGroup()
	{
		var controls = new[]
		{
			("@primary_hand", $"Primary hand · {BoundText( _controller.Document.Binding.PrimaryHand )}"),
			("@support_hand", $"Support hand · {BoundText( _controller.Document.Binding.SupportHand )}"),
			("@primary_elbow", "Primary elbow"),
			("@support_elbow", "Support elbow")
		};
		var visible = controls.Where( x => Matches( x.Item2 ) ).ToArray();
		var body = AddGroup( "Controls", visible.Length );

		foreach ( var control in visible )
		{
			var button = new WeaponAnimatorButton( control.Item2, body )
			{
				Clicked = () => _controller.SelectControl( control.Item1 ),
				Tint = _controller.Document.Workspace.SelectedControl == control.Item1
					? WeaponAnimatorTheme.Cyan * 0.48f
					: WeaponAnimatorTheme.Surface
			};
			body.Layout.Add( button );
			_itemButtons[control.Item1] = button;
			_weaponItems[control.Item1] = false;
		}
	}

	private void AddBoneGroup(
		string name,
		IReadOnlyList<HostBone> bones,
		HostSkeleton skeleton )
	{
		var visible = bones.Where( x => Matches( x.Name ) ).ToArray();
		var body = AddGroup( name, visible.Length );

		foreach ( var bone in visible.OrderBy( x => x.Index ) )
		{
			var depth = HierarchyDepth( bone, skeleton );
			var row = RigAuditPanel.Row( body );
			row.FixedHeight = 28;
			var indentation = new Widget( row )
			{
				FixedWidth = Math.Min( depth, 8 ) * 12
			};
			row.Layout.Add( indentation );
			var button = new WeaponAnimatorButton(
				bone.Name,
				row )
			{
				Clicked = () => _controller.SelectBone( bone.Name ),
				Tint = _controller.Document.Workspace.SelectedBone == bone.Name
					? (bone.IsWeaponBone ? WeaponAnimatorTheme.Amber : WeaponAnimatorTheme.Cyan) * 0.48f
					: WeaponAnimatorTheme.Surface
			};
			row.Layout.Add( button, 1 );
			body.Layout.Add( row );
			_itemButtons[bone.Name] = button;
			_weaponItems[bone.Name] = bone.IsWeaponBone;
		}
	}

	private Widget AddGroup( string name, int count )
	{
		var selectedInGroup = SelectedGroup() == name;
		if ( selectedInGroup )
			_expanded[name] = true;
		var body = new Widget( _canvas )
		{
			Layout = Layout.Column()
		};
		body.Layout.Margin = 0;
		body.Layout.Spacing = 2;
		body.Visible = GroupBodyVisible( name );
		var header = new WeaponAnimatorButton(
			GroupHeaderText( name, count ),
			_canvas )
		{
			Tint = WeaponAnimatorTheme.SurfaceRaised
		};
		header.Clicked = () =>
		{
			_expanded[name] = !_expanded[name];
			body.Visible = GroupBodyVisible( name );
			header.Text = GroupHeaderText( name, count );
			body.UpdateGeometry();
		};
		header.FixedHeight = 25;
		_canvas.Layout.Add( header );
		_canvas.Layout.Add( body );
		return body;
	}

	private bool GroupBodyVisible( string name ) =>
		_expanded[name] || !string.IsNullOrWhiteSpace( _filter );

	private string GroupHeaderText( string name, int count ) =>
		$"{(GroupBodyVisible( name ) ? "▾" : "▸")}  {name.ToUpperInvariant()}  {count}";

	private string SelectedGroup()
	{
		if ( !string.IsNullOrWhiteSpace( _controller.Document.Workspace.SelectedControl ) )
			return "Controls";
		var selected = _controller.Document.Workspace.SelectedBone;
		if ( string.IsNullOrWhiteSpace( selected ) )
			return "";
		return GroupName( HostSkeletonBuilder.BuildCached( _controller.Document ).ByName.GetValueOrDefault( selected ) );
	}

	private Dictionary<string, List<HostBone>> GroupBones( HostSkeleton skeleton )
	{
		var groups = new Dictionary<string, List<HostBone>>( StringComparer.OrdinalIgnoreCase );
		foreach ( var bone in skeleton.Bones )
		{
			var group = GroupName( bone );
			if ( !groups.TryGetValue( group, out var list ) )
				groups[group] = list = [];
			list.Add( bone );
		}
		return groups;
	}

	internal static string GroupName( HostBone? bone )
	{
		if ( bone is null )
			return "Advanced";
		if ( bone.IsWeaponBone )
			return "Weapon";
		if ( bone.Name.Contains( "finger", StringComparison.OrdinalIgnoreCase )
			|| bone.Name.Contains( "thumb", StringComparison.OrdinalIgnoreCase ) )
			return "Fingers";
		if ( bone.Name.EndsWith( "_R", StringComparison.OrdinalIgnoreCase ) )
			return "Right arm";
		if ( bone.Name.EndsWith( "_L", StringComparison.OrdinalIgnoreCase ) )
			return "Left arm";
		return "Advanced";
	}

	private static int HierarchyDepth( HostBone bone, HostSkeleton skeleton )
	{
		var depth = 0;
		var parent = bone.ParentName;
		while ( !string.IsNullOrWhiteSpace( parent )
			&& skeleton.ByName.TryGetValue( parent, out var parentBone )
			&& depth < 16 )
		{
			depth++;
			parent = parentBone.ParentName;
		}
		return depth;
	}

	private bool Matches( string text ) =>
		string.IsNullOrWhiteSpace( _filter )
		|| text.Contains( _filter, StringComparison.OrdinalIgnoreCase );

	private void RefreshSelection()
	{
		RefreshSelection( reveal: true );
	}

	private void RefreshSelection( bool reveal )
	{
		if ( _rebuilding )
			return;

		var selected = SelectedItem();
		var group = SelectedGroup();
		if ( !string.IsNullOrWhiteSpace( group )
			&& _expanded.TryGetValue( group, out var expanded )
			&& !expanded )
		{
			_expanded[group] = true;
			Rebuild();
			return;
		}

		foreach ( var item in _itemButtons )
		{
			var isSelected = item.Key.Equals(
				selected,
				StringComparison.OrdinalIgnoreCase );
			var accent = _weaponItems.GetValueOrDefault( item.Key )
				? WeaponAnimatorTheme.Amber
				: WeaponAnimatorTheme.Cyan;
			item.Value.Tint = isSelected
				? accent * 0.48f
				: WeaponAnimatorTheme.Surface;
		}

		if ( reveal
			&& !string.IsNullOrWhiteSpace( selected )
			&& _itemButtons.TryGetValue( selected, out var button ) )
			RevealIfNeeded( button );
	}

	private void RevealIfNeeded( Widget button )
	{
		if ( button.Height <= 0 || _scroll.Height <= 0 )
			return;

		var viewportTop = _scroll.ScreenPosition.y;
		var viewportBottom = viewportTop + _scroll.Height;
		var itemTop = button.ScreenPosition.y;
		var itemBottom = itemTop + button.Height;
		if ( itemTop < viewportTop )
		{
			_scroll.VerticalScrollbar.Value -=
				(viewportTop - itemTop).CeilToInt();
		}
		else if ( itemBottom > viewportBottom )
		{
			_scroll.VerticalScrollbar.Value +=
				(itemBottom - viewportBottom).CeilToInt();
		}
	}

	private string SelectedItem() =>
		!string.IsNullOrWhiteSpace( _controller.Document.Workspace.SelectedControl )
			? _controller.Document.Workspace.SelectedControl
			: _controller.Document.Workspace.SelectedBone;

	private void UpdateControlLabels()
	{
		var labels = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase )
		{
			["@primary_hand"] =
				$"Primary hand · {BoundText( _controller.Document.Binding.PrimaryHand )}",
			["@support_hand"] =
				$"Support hand · {BoundText( _controller.Document.Binding.SupportHand )}",
			["@primary_elbow"] = "Primary elbow",
			["@support_elbow"] = "Support elbow"
		};
		foreach ( var item in labels )
		{
			if ( _itemButtons.TryGetValue( item.Key, out var button ) )
				button.Text = item.Value;
		}

		foreach ( var bone in HostSkeletonBuilder.BuildCached( _controller.Document )
			.Bones.Where( x => x.IsWeaponBone ) )
		{
			if ( !_itemButtons.TryGetValue( bone.Name, out var button ) )
				continue;
			button.Icon = _controller.GetVisibilityPart( bone.Name ) is null
				? ""
				: "visibility";
		}
	}

	internal static string StructureSignature( HostSkeleton skeleton ) =>
		string.Join(
			"|",
			skeleton.Bones.Select( bone =>
				$"{bone.Name}>{bone.ParentName}:{bone.IsWeaponBone}" ) );

	private static string BoundText( RigTarget target ) => target.IsBound ? "bound" : "unbound";
}

public sealed partial class SelectedControlInspectorPanel : Widget
{
	private readonly WeaponAnimatorController _controller;
	private readonly Label _type;
	private readonly Label _name;
	private readonly Label _details;
	private readonly Label _keyState;
	private readonly Widget _identity;
	private readonly Widget _identitySpine;
	private readonly Widget _transform;
	private Widget _checklist = null!;
	private readonly List<Action> _refreshers = [];
	private bool _checklistExpanded;
	private bool _rebuildingTransformFields;
	private bool _refreshingTransformFields;
	private bool _lastLocalGizmos;
	private int _transformFieldGeneration;

	public event Action<string, ValidationSeverity>? StatusChanged;

	public SelectedControlInspectorPanel(
		WeaponAnimatorController controller,
		Widget? parent = null ) : base( parent )
	{
		_controller = controller;
		Layout = Layout.Column();
		Layout.Margin = 0;
		Layout.Spacing = 0;

		_identity = new Widget( this );
		_identity.Layout = Layout.Row();
		_identity.Layout.Margin = 0;
		_identity.Layout.Spacing = 0;
		_identity.SetStyles(
			"background-color: rgb(25,28,32); border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;" );
		_identitySpine = new Widget( _identity ) { FixedWidth = 3 };
		_identitySpine.SetStyles(
			$"background-color: {WeaponAnimatorTheme.Cyan.Hex}; border: none; border-radius: 0px;" );
		_identity.Layout.Add( _identitySpine );
		var identityContent = new Widget( _identity );
		identityContent.Layout = Layout.Column();
		identityContent.Layout.Margin = new Sandbox.UI.Margin( 12, 11, 14, 11 );
		identityContent.Layout.Spacing = 3;
		_type = WeaponAnimatorTheme.SectionLabel( "NO SELECTION", identityContent, WeaponAnimatorTheme.Cyan );
		_name = WeaponAnimatorTheme.Label( "Select a control or bone", identityContent );
		_name.SetStyles(
			"background-color: transparent; border: none; padding: 0px;" +
			$"font-size: 17px; font-weight: 500; color: {WeaponAnimatorTheme.Text.Hex};" );
		_details = WeaponAnimatorTheme.Label( "Choose an item in the rig browser.", identityContent, true );
		_keyState = WeaponAnimatorTheme.Label( "No key", identityContent, true );
		identityContent.Layout.Add( _type );
		identityContent.Layout.Add( _name );
		identityContent.Layout.Add( _details );
		identityContent.Layout.Add( _keyState );
		_identity.Layout.Add( identityContent, 1 );
		Layout.Add( _identity );

		Layout.Add( BuildChecklist() );
		_transform = new Widget( this );
		_transform.Layout = Layout.Column();
		_transform.Layout.Margin = new Sandbox.UI.Margin( 10, 8, 10, 8 );
		_transform.Layout.Spacing = 6;
		Layout.Add( _transform );

		var tools = new AnimationInspectorPanel( controller, this, controlToolsOnly: true );
		tools.StatusChanged += ( message, severity ) => StatusChanged?.Invoke( message, severity );
		Layout.Add( tools, 1 );
		_controller.SelectionChanged += RebuildTransform;
		_controller.DocumentChanged += Refresh;
		_controller.PoseChanged += RefreshPose;
		_controller.TimelineChanged += Refresh;
		RebuildTransform();
	}

	public override void OnDestroyed()
	{
		_controller.SelectionChanged -= RebuildTransform;
		_controller.DocumentChanged -= Refresh;
		_controller.PoseChanged -= RefreshPose;
		_controller.TimelineChanged -= Refresh;
		base.OnDestroyed();
	}

	private Widget BuildChecklist()
	{
		_checklist = new Widget( this );
		_checklist.Layout = Layout.Column();
		_checklist.Layout.Margin = new Sandbox.UI.Margin( 10, 7, 10, 7 );
		_checklist.Layout.Spacing = 4;
		_checklist.SetStyles(
			"background-color: rgb(22,25,28); border: none; border-bottom: 1px solid rgba(255,255,255,0.06);" );
		RebuildChecklist();
		return _checklist;
	}

	private void RebuildChecklist()
	{
		if ( _checklist is null || !_checklist.IsValid() )
			return;
		_checklist.Layout.Clear( true );
		_checklist.Visible = !_controller.Document.Binding.ChecklistDismissed;
		if ( !_checklist.Visible )
			return;

		var states = ChecklistStates();
		var complete = states.Count( x => x.Complete );
		var next = states.FirstOrDefault( x => !x.Complete );
		var header = RigAuditPanel.Row( _checklist );
		var toggle = new WeaponAnimatorButton(
			$"{complete}/5  Grip setup{(next.Label is null ? " · Complete" : $" · Next: {next.Label}")}",
			"checklist",
			header )
		{
			Clicked = () =>
			{
				_checklistExpanded = !_checklistExpanded;
				RebuildChecklist();
			},
			Tint = WeaponAnimatorTheme.Surface
		};
		header.Layout.Add( toggle, 1 );
		var dismiss = new WeaponAnimatorButton( "", "close", header )
		{
			Clicked = () => _controller.Mutate(
				"Dismiss binding checklist",
				document => document.Binding.ChecklistDismissed = true ),
			Tint = WeaponAnimatorTheme.Surface,
			ToolTip = "Dismiss setup guide"
		};
		dismiss.FixedWidth = 32;
		header.Layout.Add( dismiss );
		_checklist.Layout.Add( header );
		if ( !_checklistExpanded )
			return;

		foreach ( var state in states )
		{
			var captured = state;
			_checklist.Layout.Add( new WeaponAnimatorButton(
				$"{(captured.Complete ? "✓" : "○")}  {captured.Label}",
				_checklist )
			{
				Clicked = captured.Select,
				Tint = captured.Complete
					? WeaponAnimatorTheme.Green * 0.22f
					: WeaponAnimatorTheme.Surface
			} );
		}
	}

	private List<(string? Label, bool Complete, Action Select)> ChecklistStates()
	{
		var document = _controller.Document;
		var clip = document.GetSelectedClip();
		var hasElbow = clip?.Tracks.Any( x =>
			(x.Target is "@primary_elbow" or "@support_elbow") && x.Keys.Count > 0 ) == true;
		var hasFinger = clip?.Tracks.Any( x =>
			x.Target.Contains( "finger", StringComparison.OrdinalIgnoreCase ) && x.Keys.Count > 0 ) == true;
		return
		[
			("Bind primary hand", document.Binding.PrimaryHand.IsBound, () => _controller.SelectControl( "@primary_hand" )),
			("Bind support hand",
				document.Binding.Configuration == GripConfiguration.OneHanded
					|| document.Binding.SupportHand.IsBound,
				() => _controller.SelectControl( "@support_hand" )),
			("Adjust elbow poles", hasElbow, () => _controller.SelectControl( "@primary_elbow" )),
			("Pose fingers", hasFinger, () => _controller.SelectBone( "finger_index_0_R" )),
			("Save default grip pose",
				document.Binding.GripPoses.Count > 0,
				() => _controller.SelectControl( "@primary_hand" ))
		];
	}

	private void RebuildTransform()
	{
		_rebuildingTransformFields = true;
		_lastLocalGizmos = _controller.Document.Workspace.LocalGizmos;
		_transformFieldGeneration++;
		try
		{
			RebuildChecklist();
			_transform.Layout.Clear( true );
			_refreshers.Clear();
			var context = SelectionTransformContext.Resolve( _controller );
			if ( context is null )
			{
				RefreshIdentity( null );
				return;
			}

			RefreshIdentity( context );
			var mode = RigAuditPanel.Row( _transform );
			mode.Layout.Add( WeaponAnimatorTheme.SectionLabel(
				"TRANSFORM",
				mode,
				context.Kind == RigControlKind.Weapon
					? WeaponAnimatorTheme.Amber
					: WeaponAnimatorTheme.Cyan ) );
			mode.Layout.AddStretchCell();
			mode.Layout.Add( ToggleAutoKey( mode ) );
			_transform.Layout.Add( mode );

			var space = context.LocalSpace ? "Local" : "World";
			AddVectorRow( $"{space} Position", 0.05f, context, TransformPart.Position );
			AddVectorRow( $"{space} Rotation", 0.5f, context, TransformPart.Rotation );
			AddVectorRow( $"{space} Scale", 0.005f, context, TransformPart.Scale );

			var actions = RigAuditPanel.Row( _transform );
			actions.Layout.Add( WeaponAnimatorTheme.Button(
				"Key pose",
				"diamond",
				() =>
				{
					var current = SelectionTransformContext.Resolve( _controller );
					if ( current is not null )
						_controller.CommitWorkingPose(
							current.Target,
							current.Kind,
							current.LocalTransform );
				},
				actions,
				true ) );
			actions.Layout.Add( WeaponAnimatorTheme.Button(
				"Revert",
				"restart_alt",
				() =>
				{
					var current = SelectionTransformContext.Resolve( _controller );
					if ( current is not null )
						_controller.DiscardWorkingPose( current.Target );
				},
				actions ) );
			if ( !context.Target.StartsWith( "@", StringComparison.Ordinal ) )
			{
				actions.Layout.Add( WeaponAnimatorTheme.Button(
					"Reset bind",
					"settings_backup_restore",
					() =>
					{
						var current = SelectionTransformContext.Resolve( _controller );
						var skeleton = HostSkeletonBuilder.BuildCached( _controller.Document );
						if ( current is null
							|| !skeleton.ByName.TryGetValue( current.Target, out var bone ) )
							return;
						_controller.ApplyTransformEdit(
							current.Target,
							current.Kind,
							skeleton.GetBindLocal( bone ) );
					},
					actions ) );
			}
			_transform.Layout.Add( actions );
			if ( context.Kind == RigControlKind.Weapon )
				AddVisibilityEditor( context );
			Refresh();
		}
		finally
		{
			_rebuildingTransformFields = false;
		}
	}

	private Button ToggleAutoKey( Widget parent )
	{
		var button = new WeaponAnimatorButton( "Auto-key", "fiber_manual_record", parent )
		{
			IsToggle = true,
			IsChecked = _controller.Document.Workspace.AutoKey,
			Tint = _controller.Document.Workspace.AutoKey
				? WeaponAnimatorTheme.Coral * 0.45f
				: WeaponAnimatorTheme.SurfaceRaised
		};
		button.Toggled = () =>
		{
			_controller.Mutate(
				"Auto-key",
				document => document.Workspace.AutoKey = button.IsChecked );
			RebuildTransform();
		};
		return button;
	}

	private void AddVectorRow(
		string label,
		float sensitivity,
		SelectionTransformContext initial,
		TransformPart part )
	{
		var row = RigAuditPanel.Row( _transform );
		var title = WeaponAnimatorTheme.Label( label, row, true );
		title.FixedWidth = 92;
		row.Layout.Add( title );
		var edits = new LineEdit[3];
		var fieldGeneration = _transformFieldGeneration;
		var axes = new[] { "X", "Y", "Z" };
		var colors = new[]
		{
			WeaponAnimatorTheme.Coral,
			WeaponAnimatorTheme.Green,
			new Color( 0.30f, 0.56f, 0.96f )
		};

		for ( var index = 0; index < 3; index++ )
		{
			var captured = index;
			var field = new Widget( row )
			{
				MinimumWidth = 76,
				FixedHeight = 26,
				Layout = Layout.Row()
			};
			field.Layout.Margin = 0;
			field.Layout.Spacing = 0;
			var edit = new LineEdit( field ) { FixedHeight = 26 };
			edit.SetStyles( WeaponAnimatorTheme.InputStyle );
			edit.EditingFinished += () =>
			{
				var current = SelectionTransformContext.Resolve( _controller );
				if ( !CanApplyFieldEdit(
					_rebuildingTransformFields,
					_refreshingTransformFields,
					fieldGeneration,
					_transformFieldGeneration,
					initial.Target,
					initial.Kind,
					current ) )
					return;
				if ( !float.TryParse(
					edit.Text,
					NumberStyles.Float,
					CultureInfo.InvariantCulture,
					out var value )
					|| !WeaponAnimationMath.IsFinite( value ) )
					return;
				ApplyAxisValue( part, captured, value, false );
			};
			field.Layout.Add( new ScrubHandle(
				axes[captured],
				colors[captured],
				sensitivity,
				() => GetVector(
					SelectionTransformContext.Resolve( _controller )?.DisplayTransform
						?? initial.DisplayTransform,
					part )[captured],
				() => _controller.BeginContinuousEdit( $"{label} {axes[captured]}" ),
				value => ApplyAxisValue( part, captured, value, true ),
				_controller.EndContinuousEdit,
				field ) );
			field.Layout.Add( edit, 1 );
			row.Layout.Add( field, 1 );
			edits[index] = edit;
		}

		_refreshers.Add( () =>
		{
			var current = SelectionTransformContext.Resolve( _controller );
			if ( current is null )
				return;
			var vector = GetVector( current.DisplayTransform, part );
			for ( var index = 0; index < 3; index++ )
			{
				var text = vector[index].ToString( "0.###", CultureInfo.InvariantCulture );
				if ( edits[index].Text != text )
					edits[index].Value = text;
			}
		} );
		_transform.Layout.Add( row );
	}

	internal static bool CanApplyFieldEdit(
		bool rebuilding,
		bool refreshing,
		int fieldGeneration,
		int currentGeneration,
		string expectedTarget,
		RigControlKind expectedKind,
		SelectionTransformContext? current ) =>
		!rebuilding
		&& !refreshing
		&& fieldGeneration == currentGeneration
		&& current is not null
		&& current.Target.Equals( expectedTarget, StringComparison.OrdinalIgnoreCase )
		&& current.Kind == expectedKind;

	private void ApplyAxisValue(
		TransformPart part,
		int axis,
		float value,
		bool continuous )
	{
		var context = SelectionTransformContext.Resolve( _controller );
		if ( context is null )
			return;
		var displayed = context.DisplayTransform;
		var vector = GetVector( displayed, part );
		vector[axis] = part == TransformPart.Scale ? MathF.Max( value, 0.0001f ) : value;
		displayed = SetVector( displayed, part, vector );
		var local = context.ToLocal( displayed );
		if ( continuous )
			_controller.UpdateTransformEditContinuous( context.Target, context.Kind, local );
		else
			_controller.ApplyTransformEdit( context.Target, context.Kind, local );
	}

	private void Refresh()
	{
		if ( _lastLocalGizmos != _controller.Document.Workspace.LocalGizmos )
		{
			RebuildTransform();
			return;
		}

		RebuildChecklist();
		RefreshPose();
	}

	private void RefreshPose()
	{
		var context = SelectionTransformContext.Resolve( _controller );
		RefreshIdentity( context );
		_refreshingTransformFields = true;
		try
		{
			foreach ( var refresh in _refreshers )
				refresh();
		}
		finally
		{
			_refreshingTransformFields = false;
		}
	}

	private void RefreshIdentity( SelectionTransformContext? context )
	{
		if ( context is null )
		{
			_identity.SetStyles(
				"background-color: rgb(25,28,32); border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;" );
			_identitySpine.SetStyles(
				$"background-color: {WeaponAnimatorTheme.Muted.Hex}; border: none; border-radius: 0px;" );
			_type.Text = "NO SELECTION";
			_name.Text = "Select a control or bone";
			_details.Text = "Choose an item in the rig browser.";
			_keyState.Text = "No key";
			return;
		}

		_type.Text = context.TypeName;
		var accent = context.Kind == RigControlKind.Weapon
			? WeaponAnimatorTheme.Amber
			: WeaponAnimatorTheme.Cyan;
		_type.Color = accent;
		_identitySpine.SetStyles(
			$"background-color: {accent.Hex}; border: none; border-radius: 0px;" );
		_identity.SetStyles(
			"background-color: rgb(25,28,32);" +
			"border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;" );
		_name.Text = context.DisplayName;
		_details.Text = string.IsNullOrWhiteSpace( context.ParentName )
			? "No parent"
			: $"Parent: {context.ParentName}";
		var clip = _controller.Document.GetSelectedClip();
		var working = clip is not null
			&& _controller.Document.Workspace.GetWorkingPose( clip.Id, context.Target ) is not null;
		_keyState.Text = working
			? "◆ Unkeyed changes"
			: _controller.HasKeyAtPlayhead( context.Target )
				? "◆ Keyed at playhead"
				: "◇ No key at playhead";
		_keyState.Color = working
			? WeaponAnimatorTheme.Amber
			: _controller.HasKeyAtPlayhead( context.Target )
				? WeaponAnimatorTheme.Cyan
				: WeaponAnimatorTheme.Muted;
	}

	private static Vector3 GetVector( Transform transform, TransformPart part ) => part switch
	{
		TransformPart.Position => transform.Position,
		TransformPart.Rotation => new Vector3(
			transform.Rotation.Angles().pitch,
			transform.Rotation.Angles().yaw,
			transform.Rotation.Angles().roll ),
		_ => transform.Scale
	};

	private static Transform SetVector(
		Transform transform,
		TransformPart part,
		Vector3 value ) => part switch
	{
		TransformPart.Position => transform.WithPosition( value ),
		TransformPart.Rotation => transform.WithRotation( Rotation.From( value.x, value.y, value.z ).Normal ),
		_ => transform.WithScale( value )
	};

	private enum TransformPart
	{
		Position,
		Rotation,
		Scale
	}
}

internal sealed class SelectionTransformContext
{
	public string Target { get; init; } = "";
	public string DisplayName { get; init; } = "";
	public string ParentName { get; init; } = "";
	public RigControlKind Kind { get; init; }
	public Transform LocalTransform { get; init; }
	public Transform WorldTransform { get; init; }
	public Transform? ParentTransform { get; init; }
	public bool LocalSpace { get; init; }
	public Transform DisplayTransform => LocalSpace ? LocalTransform : WorldTransform;
	public string TypeName => Target switch
	{
		"@primary_hand" or "@support_hand" => "HAND IK TARGET",
		"@primary_elbow" or "@support_elbow" => "ELBOW POLE",
		_ when Kind == RigControlKind.Weapon => "WEAPON BONE",
		_ when Kind == RigControlKind.Camera => "CAMERA BONE",
		_ => "ARM BONE"
	};

	public Transform ToLocal( Transform displayed ) =>
		LocalSpace || ParentTransform is null
			? displayed
			: ParentTransform.Value.ToLocal( displayed );

	public static SelectionTransformContext? Resolve( WeaponAnimatorController controller )
	{
		var document = controller.Document;
		var clip = document.GetSelectedClip();
		var skeleton = HostSkeletonBuilder.BuildCached( document );
		var pose = AnimationPoseEvaluator.Evaluate(
			document,
			skeleton,
			clip,
			document.Workspace.TimelineTime,
			includeWorkingPose: true );
		var control = document.Workspace.SelectedControl;
		if ( !string.IsNullOrWhiteSpace( control ) )
		{
			var target = control switch
			{
				"@primary_hand" => document.Binding.PrimaryHand,
				"@support_hand" => document.Binding.SupportHand,
				"@primary_elbow" => document.Binding.PrimaryElbowPole,
				"@support_elbow" => document.Binding.SupportElbowPole,
				_ => null
			};
			if ( target is null )
				return null;
			var local = clip is not null
				&& document.Workspace.GetWorkingPose( clip.Id, control ) is { } working
					? working.Transform
					: clip?.Tracks.FirstOrDefault( x =>
						x.Target.Equals( control, StringComparison.OrdinalIgnoreCase ) ) is { } track
						? WeaponAnimationMath.SampleTrack(
							track,
							document.Workspace.TimelineTime,
							target.Transform )
						: target.Transform;
			Transform? parent = null;
			if ( !string.IsNullOrWhiteSpace( target.AttachedBone )
				&& pose.Model.TryGetValue( target.AttachedBone, out var attached ) )
				parent = attached;
			var world = parent is null
				? local
				: new Transform(
					parent.Value.PointToWorld( local.Position ),
					parent.Value.Rotation * local.Rotation,
					parent.Value.Scale * local.Scale );
			return new SelectionTransformContext
			{
				Target = control,
				DisplayName = target.Name,
				ParentName = target.AttachedBone,
				Kind = RigControlKind.Arm,
				LocalTransform = local,
				WorldTransform = world,
				ParentTransform = parent,
				LocalSpace = document.Workspace.LocalGizmos
			};
		}

		var selected = document.Workspace.SelectedBone;
		if ( string.IsNullOrWhiteSpace( selected )
			|| !skeleton.ByName.TryGetValue( selected, out var bone )
			|| !pose.Local.TryGetValue( selected, out var boneLocal )
			|| !pose.Model.TryGetValue( selected, out var boneWorld ) )
			return null;
		Transform? boneParent = null;
		if ( !string.IsNullOrWhiteSpace( bone.ParentName )
			&& pose.Model.TryGetValue( bone.ParentName, out var parentModel ) )
			boneParent = parentModel;
		return new SelectionTransformContext
		{
			Target = selected,
			DisplayName = selected,
			ParentName = bone.ParentName,
			Kind = bone.IsWeaponBone ? RigControlKind.Weapon : RigControlKind.Arm,
			LocalTransform = boneLocal,
			WorldTransform = boneWorld,
			ParentTransform = boneParent,
			LocalSpace = document.Workspace.LocalGizmos
		};
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 46,
    "Files": [
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Services/WeaponMaterialPipeline.cs",
            "FileName": "WeaponMaterialPipeline.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.IO;\nusing System.Linq;\nusing System.Text;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\ninternal static class WeaponMaterialPipeline\n{\n\tprivate const string PreviewMaterialFormatVersion = \"preview-material-v2\";\n\n\tprivate static readonly HashSet<string> SupportedImageExtensions =\n\t\tnew( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\t\".png\", \".tga\", \".jpg\", \".jpeg\", \".bmp\", \".tif\", \".tiff\", \".dds\", \".exr\"\n\t\t};\n\n\tprivate static readonly string[] NearbyFolderNames =\n\t\t[\"textures\", \"texture\", \"materials\", \"material\", \"maps\"];\n\n\tprivate sealed record TextureCandidate(\n\t\tstring Path,\n\t\tstring GroupName,\n\t\tWeaponTextureChannel Channel,\n\t\tint Priority );\n\n\tinternal sealed record GeneratedTextureCopy(\n\t\tstring RelativePath,\n\t\tstring SourceAbsolute );\n\n\tpublic static List<SourceMaterialBinding> DiscoverAndPreparePreview(\n\t\tstring absoluteSource,\n\t\tstring cacheRoot,\n\t\tAsset modelAsset,\n\t\tModel? model,\n\t\tList<RigAuditIssue> issues,\n\t\tIEnumerable<string>? knownMaterialSlots = null )\n\t{\n\t\tvar candidates = DiscoverTextureCandidates( absoluteSource );\n\t\tvar slots = DiscoverMaterialSlots( modelAsset, model );\n\t\tslots.AddRange( knownMaterialSlots?\n\t\t\t.Where( slot => !string.IsNullOrWhiteSpace( slot ) )\n\t\t\t?? [] );\n\t\tvar embeddedSlots = DiscoverEmbeddedMaterialNames(\n\t\t\tabsoluteSource,\n\t\t\tcandidates.Select( candidate => candidate.GroupName ) )\n\t\t\t.Select( name => $\"{name}.vmat\" )\n\t\t\t.ToArray();\n\t\tslots.AddRange( embeddedSlots );\n\t\tvar embeddedSet = embeddedSlots.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tslots = slots\n\t\t\t.Where( slot => !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )\n\t\t\t.GroupBy( slot => NormalizeName( Path.GetFileNameWithoutExtension( slot ) ) )\n\t\t\t.Select( group => group.FirstOrDefault( embeddedSet.Contains ) ?? group.First() )\n\t\t\t.ToList();\n\t\tif ( slots.Count == 0 )\n\t\t{\n\t\t\t// Some interchange compilers omit unresolved material metadata. Texture set names\n\t\t\t// are the best deterministic fallback for the original slot labels.\n\t\t\tslots.AddRange( candidates\n\t\t\t\t.Select( candidate => candidate.GroupName )\n\t\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t\t.Select( name => $\"{name}.vmat\" ) );\n\t\t}\n\n\t\tvar groups = candidates\n\t\t\t.GroupBy( candidate => NormalizeName( candidate.GroupName ) )\n\t\t\t.Where( group => !string.IsNullOrWhiteSpace( group.Key ) )\n\t\t\t.ToDictionary(\n\t\t\t\tgroup => group.Key,\n\t\t\t\tgroup => group.ToArray(),\n\t\t\t\tStringComparer.OrdinalIgnoreCase );\n\t\tvar bindings = new List<SourceMaterialBinding>();\n\t\tvar usedNames = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var slot in slots\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( name => name, StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tvar displayName = Path.GetFileNameWithoutExtension( slot );\n\t\t\tvar outputName = UniqueOutputName(\n\t\t\t\tWeaponAnimationDocument.Slugify( displayName ),\n\t\t\t\tusedNames );\n\t\t\tvar binding = new SourceMaterialBinding\n\t\t\t{\n\t\t\t\tSourceMaterialPath = StoredMaterialSlot( slot ),\n\t\t\t\tName = displayName,\n\t\t\t\tOutputName = outputName\n\t\t\t};\n\n\t\t\tvar matchingGroup = FindBestGroup( displayName, groups );\n\t\t\tif ( matchingGroup is not null )\n\t\t\t{\n\t\t\t\tforeach ( var channelGroup in matchingGroup\n\t\t\t\t\t.GroupBy( candidate => candidate.Channel )\n\t\t\t\t\t.OrderBy( group => group.Key ) )\n\t\t\t\t{\n\t\t\t\t\tvar candidate = channelGroup\n\t\t\t\t\t\t.OrderByDescending( item => item.Priority )\n\t\t\t\t\t\t.ThenBy( item => item.Path, StringComparer.OrdinalIgnoreCase )\n\t\t\t\t\t\t.First();\n\t\t\t\t\tvar hash = WeaponSourceImporter.HashFile( candidate.Path );\n\t\t\t\t\tvar assetPath = EnsureTextureInsideAssets(\n\t\t\t\t\t\tcandidate.Path,\n\t\t\t\t\t\tcacheRoot,\n\t\t\t\t\t\thash );\n\t\t\t\t\tbinding.Textures.Add( new SourceTextureMap\n\t\t\t\t\t{\n\t\t\t\t\t\tChannel = candidate.Channel,\n\t\t\t\t\t\tOriginalPath = candidate.Path,\n\t\t\t\t\t\tAssetPath = assetPath,\n\t\t\t\t\t\tSha256 = hash\n\t\t\t\t\t} );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( binding.FindTexture( WeaponTextureChannel.PackedOrm ) is not null\n\t\t\t\t&& !binding.HasUsableTextures )\n\t\t\t{\n\t\t\t\tissues.Add( new RigAuditIssue\n\t\t\t\t{\n\t\t\t\t\tCode = \"material.packed_orm\",\n\t\t\t\t\tMessage =\n\t\t\t\t\t\t$\"Material '{displayName}' only has a packed ORM texture. \"\n\t\t\t\t\t\t+ \"Separate color, normal, roughness, or metalness maps are required \"\n\t\t\t\t\t\t+ \"for automatic assignment.\",\n\t\t\t\t\tSeverity = ValidationSeverity.Warning\n\t\t\t\t} );\n\t\t\t}\n\t\t\telse if ( !binding.HasUsableTextures )\n\t\t\t{\n\t\t\t\tissues.Add( new RigAuditIssue\n\t\t\t\t{\n\t\t\t\t\tCode = \"material.textures_missing\",\n\t\t\t\t\tMessage =\n\t\t\t\t\t\t$\"No nearby texture set matched material '{displayName}'. \"\n\t\t\t\t\t\t+ \"That slot will use the default material.\",\n\t\t\t\t\tSeverity = ValidationSeverity.Warning\n\t\t\t\t} );\n\t\t\t}\n\t\t\telse if ( binding.FindTexture( WeaponTextureChannel.PackedOrm ) is not null\n\t\t\t\t&& binding.FindTexture( WeaponTextureChannel.Roughness ) is null\n\t\t\t\t&& binding.FindTexture( WeaponTextureChannel.Metalness ) is null )\n\t\t\t{\n\t\t\t\tissues.Add( new RigAuditIssue\n\t\t\t\t{\n\t\t\t\t\tCode = \"material.packed_orm\",\n\t\t\t\t\tMessage =\n\t\t\t\t\t\t$\"Material '{displayName}' only has a packed ORM texture. \"\n\t\t\t\t\t\t+ \"Separate roughness and metalness maps are required for automatic assignment.\",\n\t\t\t\t\tSeverity = ValidationSeverity.Warning\n\t\t\t\t} );\n\t\t\t}\n\n\t\t\tbindings.Add( binding );\n\t\t}\n\n\t\tPreparePreviewAssets( bindings, cacheRoot );\n\t\treturn bindings;\n\t}\n\n\tpublic static IReadOnlyList<HostMaterialRemap> PreviewRemaps(\n\t\tIEnumerable<SourceMaterialBinding> bindings ) =>\n\t\tbindings\n\t\t\t.Where( binding => !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )\n\t\t\t.Select( binding => new HostMaterialRemap(\n\t\t\t\tResourceMaterialSlot( binding.SourceMaterialPath ),\n\t\t\t\tbinding.HasUsableTextures\n\t\t\t\t\t&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )\n\t\t\t\t\t\t? binding.PreviewMaterialPath\n\t\t\t\t\t\t: \"materials/default.vmat\" ) )\n\t\t\t.ToArray();\n\n\tpublic static IReadOnlyList<HostMaterialRemap> OutputRemaps(\n\t\tWeaponAnimationDocument document,\n\t\tstring relativeRoot )\n\t{\n\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\treturn document.Source.Materials\n\t\t\t.Where( binding => !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )\n\t\t\t.Select( binding => new HostMaterialRemap(\n\t\t\t\tResourceMaterialSlot( binding.SourceMaterialPath ),\n\t\t\t\tbinding.HasUsableTextures\n\t\t\t\t\t? $\"{relativeRoot}/materials/{slug}_{binding.OutputName}.vmat\"\n\t\t\t\t\t: \"materials/default.vmat\" ) )\n\t\t\t.ToArray();\n\t}\n\n\tpublic static bool RequiresPreviewRefresh( WeaponAnimationDocument document ) =>\n\t\tdocument.Source.NeedsModelDocWrapper\n\t\t&& (document.Source.Materials.Count == 0\n\t\t\t|| document.Source.CompiledModelPath.StartsWith(\n\t\t\t\t\".weaponanim-cache/\",\n\t\t\t\tStringComparison.OrdinalIgnoreCase )\n\t\t\t|| document.Source.Materials.Any( binding =>\n\t\t\t\tPath.GetExtension( binding.SourceMaterialPath ).Equals(\n\t\t\t\t\t\".vmat\",\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t|| document.Source.Materials.Any( binding =>\n\t\t\t\tbinding.HasUsableTextures\n\t\t\t\t&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )\n\t\t\t\t&& (binding.PreviewMaterialPath.StartsWith(\n\t\t\t\t\t\t\".weaponanim-cache/\",\n\t\t\t\t\t\tStringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t|| binding.PreviewMaterialPath.Contains(\n\t\t\t\t\t\t\"/texture-definitions/\",\n\t\t\t\t\t\tStringComparison.OrdinalIgnoreCase )) ));\n\n\tpublic static Dictionary<string, string> BuildOutputTextFiles(\n\t\tWeaponAnimationDocument document,\n\t\tstring relativeRoot )\n\t{\n\t\tvar files = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\tforeach ( var binding in document.Source.Materials\n\t\t\t.Where( binding => binding.HasUsableTextures ) )\n\t\t{\n\t\t\tvar texturePaths = new Dictionary<WeaponTextureChannel, string>();\n\t\t\tforeach ( var texture in binding.Textures\n\t\t\t\t.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )\n\t\t\t{\n\t\t\t\tvar imageName = OutputTextureImageName( slug, binding, texture );\n\t\t\t\tvar imageRelative = $\"textures/{imageName}\";\n\t\t\t\tvar texturePath = $\"{relativeRoot}/{imageRelative}\";\n\t\t\t\ttexturePaths[texture.Channel] = texturePath;\n\t\t\t}\n\n\t\t\tfiles[$\"materials/{slug}_{binding.OutputName}.vmat\"] =\n\t\t\t\tWriteVmat( texturePaths );\n\t\t}\n\n\t\treturn files;\n\t}\n\n\tpublic static IReadOnlyList<GeneratedTextureCopy> BuildOutputTextureCopies(\n\t\tWeaponAnimationDocument document )\n\t{\n\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\tvar copies = new List<GeneratedTextureCopy>();\n\t\tforeach ( var binding in document.Source.Materials\n\t\t\t.Where( binding => binding.HasUsableTextures ) )\n\t\t{\n\t\t\tforeach ( var texture in binding.Textures\n\t\t\t\t.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )\n\t\t\t{\n\t\t\t\tvar source = ResolveTextureAbsolute( texture );\n\t\t\t\tcopies.Add( new GeneratedTextureCopy(\n\t\t\t\t\t$\"textures/{OutputTextureImageName( slug, binding, texture )}\",\n\t\t\t\t\tsource ) );\n\t\t\t}\n\t\t}\n\n\t\treturn copies;\n\t}\n\n\tinternal static IReadOnlyList<SourceMaterialBinding> DiscoverForTests(\n\t\tIEnumerable<string> materialSlots,\n\t\tIEnumerable<string> texturePaths )\n\t{\n\t\tvar candidates = texturePaths\n\t\t\t.Select( TryCreateCandidate )\n\t\t\t.Where( candidate => candidate is not null )\n\t\t\t.Cast<TextureCandidate>()\n\t\t\t.ToArray();\n\t\tvar groups = candidates\n\t\t\t.GroupBy( candidate => NormalizeName( candidate.GroupName ) )\n\t\t\t.ToDictionary(\n\t\t\t\tgroup => group.Key,\n\t\t\t\tgroup => group.ToArray(),\n\t\t\t\tStringComparer.OrdinalIgnoreCase );\n\t\tvar usedNames = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\treturn materialSlots\n\t\t\t.Where( slot => !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )\n\t\t\t.Select( slot =>\n\t\t{\n\t\t\tvar name = Path.GetFileNameWithoutExtension( slot );\n\t\t\tvar binding = new SourceMaterialBinding\n\t\t\t{\n\t\t\t\tSourceMaterialPath = StoredMaterialSlot( slot ),\n\t\t\t\tName = name,\n\t\t\t\tOutputName = UniqueOutputName(\n\t\t\t\t\tWeaponAnimationDocument.Slugify( name ),\n\t\t\t\t\tusedNames )\n\t\t\t};\n\t\t\tvar group = FindBestGroup( name, groups );\n\t\t\tif ( group is not null )\n\t\t\t{\n\t\t\t\tbinding.Textures = group\n\t\t\t\t\t.GroupBy( candidate => candidate.Channel )\n\t\t\t\t\t.Select( channel => channel.OrderByDescending( item => item.Priority ).First() )\n\t\t\t\t\t.Select( item => new SourceTextureMap\n\t\t\t\t\t{\n\t\t\t\t\t\tChannel = item.Channel,\n\t\t\t\t\t\tOriginalPath = item.Path,\n\t\t\t\t\t\tAssetPath = item.Path\n\t\t\t\t\t} )\n\t\t\t\t\t.ToList();\n\t\t\t}\n\t\t\treturn binding;\n\t\t} ).ToArray();\n\t}\n\n\tinternal static IReadOnlyList<string> MatchEmbeddedMaterialNamesForTests(\n\t\tIEnumerable<string> textureGroups,\n\t\tIEnumerable<string> embeddedStrings ) =>\n\t\tMatchEmbeddedMaterialNames( textureGroups, embeddedStrings );\n\n\tinternal static string PreviewRevision(\n\t\tIEnumerable<SourceMaterialBinding> bindings )\n\t{\n\t\tvar fingerprint = PreviewMaterialFormatVersion\n\t\t\t+ \"\\n\"\n\t\t\t+ string.Join(\n\t\t\t\"\\n\",\n\t\t\tbindings\n\t\t\t\t.OrderBy(\n\t\t\t\t\tbinding => binding.SourceMaterialPath,\n\t\t\t\t\tStringComparer.OrdinalIgnoreCase )\n\t\t\t\t.Select( binding =>\n\t\t\t\t\t$\"{NormalizeMaterialPath( binding.SourceMaterialPath )}|{binding.OutputName}|\"\n\t\t\t\t\t+ string.Join(\n\t\t\t\t\t\t\",\",\n\t\t\t\t\t\tbinding.Textures\n\t\t\t\t\t\t\t.OrderBy( texture => texture.Channel )\n\t\t\t\t\t\t\t.ThenBy(\n\t\t\t\t\t\t\t\ttexture => texture.AssetPath,\n\t\t\t\t\t\t\t\tStringComparer.OrdinalIgnoreCase )\n\t\t\t\t\t\t\t.Select( texture =>\n\t\t\t\t\t\t\t\t$\"{texture.Channel}:{texture.Sha256}:{texture.AssetPath}\" ) ) ) );\n\t\treturn WeaponSourceImporter.HashText( fingerprint )[..16];\n\t}\n\n\tinternal static string PreviewRevisionRoot(\n\t\tstring cacheRoot,\n\t\tIEnumerable<SourceMaterialBinding> bindings ) =>\n\t\tPath.Combine(\n\t\t\tLegalPreviewCacheRoot( cacheRoot ),\n\t\t\tPreviewRevision( bindings ) );\n\n\tinternal static IReadOnlyList<string> PreviewMaterialAbsolutePaths(\n\t\tIEnumerable<SourceMaterialBinding> bindings ) =>\n\t\tbindings\n\t\t\t.Where( binding => binding.HasUsableTextures\n\t\t\t\t&& !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath ) )\n\t\t\t.Select( binding => Path.Combine(\n\t\t\t\tWeaponSourceImporter.GetContentRoot(),\n\t\t\t\tbinding.PreviewMaterialPath.Replace(\n\t\t\t\t\t'/',\n\t\t\t\t\tPath.DirectorySeparatorChar ) ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\n\tinternal static IReadOnlyList<string> PreviewTextureAbsolutePaths(\n\t\tIEnumerable<SourceMaterialBinding> bindings ) =>\n\t\tbindings\n\t\t\t.SelectMany( binding => binding.Textures )\n\t\t\t.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm\n\t\t\t\t&& !string.IsNullOrWhiteSpace( texture.AssetPath ) )\n\t\t\t.Select( texture => Path.Combine(\n\t\t\t\tWeaponSourceImporter.GetContentRoot(),\n\t\t\t\ttexture.AssetPath.Replace(\n\t\t\t\t\t'/',\n\t\t\t\t\tPath.DirectorySeparatorChar ) ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\n\tinternal static string LegalPreviewRelativeRootForTests( string cacheRoot )\n\t{\n\t\tvar documentFolder = Path.GetFileName(\n\t\t\tcacheRoot.TrimEnd(\n\t\t\t\tPath.DirectorySeparatorChar,\n\t\t\t\tPath.AltDirectorySeparatorChar ) );\n\t\treturn $\"weaponanim_preview_cache/{documentFolder}\";\n\t}\n\n\tprivate static void PreparePreviewAssets(\n\t\tIEnumerable<SourceMaterialBinding> bindings,\n\t\tstring cacheRoot )\n\t{\n\t\tvar materialBindings = bindings.ToArray();\n\t\tvar legalCacheRoot = PreviewRevisionRoot( cacheRoot, materialBindings );\n\t\tvar materialRoot = Path.Combine( legalCacheRoot, \"materials\" );\n\t\tDirectory.CreateDirectory( materialRoot );\n\t\tAtomicFile.WriteAllText(\n\t\t\tPath.Combine( legalCacheRoot, \".weaponanim-preview-version\" ),\n\t\t\tPreviewMaterialFormatVersion );\n\n\t\t// Register source images before the directory watcher sees VMAT consumers. Otherwise\n\t\t// the dependency tracker can permanently mark a copied channel as \"stopped existing\".\n\t\tforeach ( var textureAbsolute in PreviewTextureAbsolutePaths( materialBindings ) )\n\t\t\tAssetSystem.RegisterFile( textureAbsolute );\n\n\t\tforeach ( var binding in materialBindings )\n\t\t{\n\t\t\tif ( !binding.HasUsableTextures )\n\t\t\t{\n\t\t\t\tbinding.PreviewMaterialPath = \"materials/default.vmat\";\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar texturePaths = new Dictionary<WeaponTextureChannel, string>();\n\t\t\tforeach ( var texture in binding.Textures\n\t\t\t\t.Where( texture => texture.Channel != WeaponTextureChannel.PackedOrm ) )\n\t\t\t{\n\t\t\t\ttexturePaths[texture.Channel] = texture.AssetPath;\n\t\t\t}\n\n\t\t\tvar vmatAbsolute = Path.Combine( materialRoot, $\"{binding.OutputName}.vmat\" );\n\t\t\tAtomicFile.WriteAllText( vmatAbsolute, WriteVmat( texturePaths ) );\n\t\t\tbinding.PreviewMaterialPath = WeaponSourceImporter.RelativeAssetPath( vmatAbsolute );\n\t\t}\n\t}\n\n\tprivate static List<string> DiscoverMaterialSlots( Asset modelAsset, Model? model )\n\t{\n\t\tvar slots = new List<string>();\n\t\ttry\n\t\t{\n\t\t\tslots.AddRange( modelAsset.GetUnrecognizedReferencePaths()\n\t\t\t\t.Where( IsSourceMaterialPath ) );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Warning(\n\t\t\t\t$\"[Weapon Animator] could not inspect unresolved source material slots: {ex.Message}\" );\n\t\t}\n\n\t\tif ( model is not null && !model.IsError )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tslots.AddRange( model.Materials\n\t\t\t\t\t.Select( material => material.Name )\n\t\t\t\t\t.Where( IsSourceMaterialPath ) );\n\t\t\t}\n\t\t\tcatch ( Exception ex )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\"[Weapon Animator] could not inspect compiled model material slots: {ex.Message}\" );\n\t\t\t}\n\t\t}\n\t\treturn slots\n\t\t\t.Select( NormalizeMaterialPath )\n\t\t\t.Where( path => !path.Contains(\n\t\t\t\t\".weaponanim-cache/\",\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t.Where( path => !IsIgnoredMaterialPath( path ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToList();\n\t}\n\n\tprivate static bool IsSourceMaterialPath( string? path ) =>\n\t\t!string.IsNullOrWhiteSpace( path )\n\t\t&& Path.GetExtension( path ).Equals( \".vmat\", StringComparison.OrdinalIgnoreCase );\n\n\tprivate static List<TextureCandidate> DiscoverTextureCandidates( string sourcePath )\n\t{\n\t\tvar directories = NearbyDirectories( sourcePath );\n\t\tvar files = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var directory in directories )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tforeach ( var file in Directory.EnumerateFiles( directory )\n\t\t\t\t\t.Where( file => SupportedImageExtensions.Contains( Path.GetExtension( file ) ) )\n\t\t\t\t\t.Take( 512 ) )\n\t\t\t\t{\n\t\t\t\t\tfiles.Add( Path.GetFullPath( file ) );\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( Exception ex )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\"[Weapon Animator] could not inspect nearby texture folder '{directory}': {ex.Message}\" );\n\t\t\t}\n\t\t}\n\n\t\treturn files\n\t\t\t.Select( TryCreateCandidate )\n\t\t\t.Where( candidate => candidate is not null )\n\t\t\t.Cast<TextureCandidate>()\n\t\t\t.ToList();\n\t}\n\n\tprivate static IReadOnlyList<string> DiscoverEmbeddedMaterialNames(\n\t\tstring sourcePath,\n\t\tIEnumerable<string> textureGroups )\n\t{\n\t\tif ( !Path.GetExtension( sourcePath ).Equals(\n\t\t\t\".fbx\",\n\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn [];\n\n\t\ttry\n\t\t{\n\t\t\treturn MatchEmbeddedMaterialNames(\n\t\t\t\ttextureGroups,\n\t\t\t\tReadPrintableStrings( sourcePath ) );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Warning(\n\t\t\t\t$\"[Weapon Animator] could not inspect embedded FBX material labels: {ex.Message}\" );\n\t\t\treturn [];\n\t\t}\n\t}\n\n\tprivate static IReadOnlyList<string> MatchEmbeddedMaterialNames(\n\t\tIEnumerable<string> textureGroups,\n\t\tIEnumerable<string> embeddedStrings )\n\t{\n\t\tvar strings = embeddedStrings\n\t\t\t.Where( value => value.Length is >= 2 and <= 128\n\t\t\t\t&& !value.Contains( '/' )\n\t\t\t\t&& !value.Contains( '\\\\' )\n\t\t\t\t&& !value.Contains( '.' ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\t\tvar names = new List<string>();\n\t\tforeach ( var group in textureGroups\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tvar normalizedGroup = NormalizeName( group );\n\t\t\tvar match = strings\n\t\t\t\t.Where( value => NormalizeName( value ).Equals(\n\t\t\t\t\tnormalizedGroup,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t.OrderBy( value => value.Length )\n\t\t\t\t.ThenBy( value => value, StringComparer.OrdinalIgnoreCase )\n\t\t\t\t.FirstOrDefault();\n\t\t\tif ( !string.IsNullOrWhiteSpace( match ) )\n\t\t\t\tnames.Add( match );\n\t\t}\n\t\treturn names.Distinct( StringComparer.OrdinalIgnoreCase ).ToArray();\n\t}\n\n\tprivate static IEnumerable<string> ReadPrintableStrings( string path )\n\t{\n\t\tusing var stream = File.OpenRead( path );\n\t\tvar builder = new StringBuilder();\n\t\tvar buffer = new byte[64 * 1024];\n\t\tint count;\n\t\twhile ( (count = stream.Read( buffer, 0, buffer.Length )) > 0 )\n\t\t{\n\t\t\tfor ( var index = 0; index < count; index++ )\n\t\t\t{\n\t\t\t\tvar value = buffer[index];\n\t\t\t\tif ( value is >= 32 and <= 126 )\n\t\t\t\t{\n\t\t\t\t\tif ( builder.Length < 512 )\n\t\t\t\t\t\tbuilder.Append( (char)value );\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif ( builder.Length >= 2 )\n\t\t\t\t\tyield return builder.ToString();\n\t\t\t\tbuilder.Clear();\n\t\t\t}\n\t\t}\n\t\tif ( builder.Length >= 2 )\n\t\t\tyield return builder.ToString();\n\t}\n\n\tprivate static IEnumerable<string> NearbyDirectories( string sourcePath )\n\t{\n\t\tvar sourceDirectory = Path.GetDirectoryName( sourcePath );\n\t\tif ( string.IsNullOrWhiteSpace( sourceDirectory ) )\n\t\t\tyield break;\n\n\t\tvar found = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tif ( found.Add( sourceDirectory ) )\n\t\t\tyield return sourceDirectory;\n\n\t\tforeach ( var root in new[]\n\t\t\t{\n\t\t\t\tsourceDirectory,\n\t\t\t\tDirectory.GetParent( sourceDirectory )?.FullName\n\t\t\t}.Where( root => !string.IsNullOrWhiteSpace( root ) ) )\n\t\t{\n\t\t\tforeach ( var folder in NearbyFolderNames )\n\t\t\t{\n\t\t\t\tvar candidate = Path.Combine( root!, folder );\n\t\t\t\tif ( Directory.Exists( candidate ) && found.Add( candidate ) )\n\t\t\t\t\tyield return candidate;\n\t\t\t}\n\n\t\t\tIEnumerable<string> children;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tchildren = Directory.EnumerateDirectories( root! ).ToArray();\n\t\t\t}\n\t\t\tcatch\n\t\t\t{\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tforeach ( var child in children.Where( child =>\n\t\t\t\tNearbyFolderNames.Any( folder =>\n\t\t\t\t\tPath.GetFileName( child ).Contains(\n\t\t\t\t\t\tfolder,\n\t\t\t\t\t\tStringComparison.OrdinalIgnoreCase ) ) ) )\n\t\t\t{\n\t\t\t\tif ( found.Add( child ) )\n\t\t\t\t\tyield return child;\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static TextureCandidate? TryCreateCandidate( string path )\n\t{\n\t\tvar stem = Path.GetFileNameWithoutExtension( path );\n\t\tvar normalized = NormalizeSeparators( stem );\n\t\tvar patterns = new (string Token, WeaponTextureChannel Channel, int Priority)[]\n\t\t{\n\t\t\t(\"occlusion_roughness_metallic\", WeaponTextureChannel.PackedOrm, 100),\n\t\t\t(\"occlusionroughnessmetallic\", WeaponTextureChannel.PackedOrm, 100),\n\t\t\t(\"normal_opengl\", WeaponTextureChannel.Normal, 145),\n\t\t\t(\"normal_gl\", WeaponTextureChannel.Normal, 145),\n\t\t\t(\"nrm_gl\", WeaponTextureChannel.Normal, 140),\n\t\t\t(\"normal_directx\", WeaponTextureChannel.Normal, 80),\n\t\t\t(\"normal_dx\", WeaponTextureChannel.Normal, 80),\n\t\t\t(\"nrm_dx\", WeaponTextureChannel.Normal, 75),\n\t\t\t(\"base_color\", WeaponTextureChannel.BaseColor, 120),\n\t\t\t(\"basecolor\", WeaponTextureChannel.BaseColor, 120),\n\t\t\t(\"albedo\", WeaponTextureChannel.BaseColor, 115),\n\t\t\t(\"diffuse\", WeaponTextureChannel.BaseColor, 110),\n\t\t\t(\"color\", WeaponTextureChannel.BaseColor, 100),\n\t\t\t(\"ambient_occlusion\", WeaponTextureChannel.AmbientOcclusion, 120),\n\t\t\t(\"ambientocclusion\", WeaponTextureChannel.AmbientOcclusion, 120),\n\t\t\t(\"occlusion\", WeaponTextureChannel.AmbientOcclusion, 100),\n\t\t\t(\"roughness\", WeaponTextureChannel.Roughness, 120),\n\t\t\t(\"rough\", WeaponTextureChannel.Roughness, 110),\n\t\t\t(\"metalness\", WeaponTextureChannel.Metalness, 120),\n\t\t\t(\"metallic\", WeaponTextureChannel.Metalness, 120),\n\t\t\t(\"metal\", WeaponTextureChannel.Metalness, 100),\n\t\t\t(\"normal\", WeaponTextureChannel.Normal, 110),\n\t\t\t(\"nrm\", WeaponTextureChannel.Normal, 105),\n\t\t\t(\"diff\", WeaponTextureChannel.BaseColor, 90),\n\t\t\t(\"ao\", WeaponTextureChannel.AmbientOcclusion, 90),\n\t\t\t(\"orm\", WeaponTextureChannel.PackedOrm, 90),\n\t\t\t(\"rma\", WeaponTextureChannel.PackedOrm, 85),\n\t\t\t(\"mra\", WeaponTextureChannel.PackedOrm, 85)\n\t\t};\n\n\t\tforeach ( var pattern in patterns )\n\t\t{\n\t\t\tvar marker = $\"_{pattern.Token}\";\n\t\t\tvar index = normalized.LastIndexOf( marker, StringComparison.Ordinal );\n\t\t\tif ( index < 0 && normalized.Equals( pattern.Token, StringComparison.Ordinal ) )\n\t\t\t\tindex = 0;\n\t\t\tif ( index < 0 )\n\t\t\t\tcontinue;\n\n\t\t\tvar group = normalized[..index].Trim( '_' );\n\t\t\tif ( string.IsNullOrWhiteSpace( group ) )\n\t\t\t\tcontinue;\n\t\t\treturn new TextureCandidate(\n\t\t\t\tpath,\n\t\t\t\tgroup,\n\t\t\t\tpattern.Channel,\n\t\t\t\tpattern.Priority );\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tprivate static TextureCandidate[]? FindBestGroup(\n\t\tstring materialName,\n\t\tIReadOnlyDictionary<string, TextureCandidate[]> groups )\n\t{\n\t\tvar normalizedMaterial = NormalizeName( materialName );\n\t\tvar best = groups\n\t\t\t.Select( pair => new\n\t\t\t{\n\t\t\t\tpair.Value,\n\t\t\t\tScore = MatchScore( normalizedMaterial, pair.Key )\n\t\t\t} )\n\t\t\t.OrderByDescending( item => item.Score )\n\t\t\t.FirstOrDefault();\n\t\treturn best is not null && best.Score > 0 ? best.Value : null;\n\t}\n\n\tprivate static int MatchScore( string material, string group )\n\t{\n\t\tif ( material.Equals( group, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn 10000;\n\t\tif ( material.Contains( group, StringComparison.OrdinalIgnoreCase )\n\t\t\t|| group.Contains( material, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn 1000 + Math.Min( material.Length, group.Length );\n\t\treturn 0;\n\t}\n\n\tprivate static string EnsureTextureInsideAssets(\n\t\tstring source,\n\t\tstring cacheRoot,\n\t\tstring hash )\n\t{\n\t\t// Preview revisions reference immutable, legal resource names rather than arbitrary\n\t\t// user filenames or an image which can change underneath the active model.\n\t\tvar sourceRoot = Path.Combine(\n\t\t\tLegalPreviewCacheRoot( cacheRoot ),\n\t\t\t\"source-textures\" );\n\t\tDirectory.CreateDirectory( sourceRoot );\n\t\tvar fileName =\n\t\t\t$\"{WeaponAnimationDocument.Slugify( Path.GetFileNameWithoutExtension( source ) )}\"\n\t\t\t+ $\"_{hash[..12]}{Path.GetExtension( source ).ToLowerInvariant()}\";\n\t\tvar destination = Path.Combine( sourceRoot, fileName );\n\t\tif ( !File.Exists( destination )\n\t\t\t|| !WeaponSourceImporter.HashFile( destination ).Equals(\n\t\t\t\thash,\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tFile.Copy( source, destination, true );\n\t\t}\n\t\treturn WeaponSourceImporter.RelativeAssetPath( destination );\n\t}\n\n\tprivate static string ResolveTextureAbsolute( SourceTextureMap texture )\n\t{\n\t\tif ( !string.IsNullOrWhiteSpace( texture.AssetPath ) )\n\t\t{\n\t\t\tvar candidate = Path.Combine(\n\t\t\t\tWeaponSourceImporter.GetContentRoot(),\n\t\t\t\ttexture.AssetPath.Replace( '/', Path.DirectorySeparatorChar ) );\n\t\t\tif ( File.Exists( candidate ) )\n\t\t\t\treturn candidate;\n\t\t}\n\n\t\tif ( !string.IsNullOrWhiteSpace( texture.OriginalPath )\n\t\t\t&& File.Exists( texture.OriginalPath ) )\n\t\t\treturn Path.GetFullPath( texture.OriginalPath );\n\n\t\tthrow new FileNotFoundException(\n\t\t\t$\"Texture source for {texture.Channel} is missing.\",\n\t\t\ttexture.AssetPath );\n\t}\n\n\tprivate static string OutputTextureImageName(\n\t\tstring slug,\n\t\tSourceMaterialBinding binding,\n\t\tSourceTextureMap texture )\n\t{\n\t\tvar extension = Path.GetExtension(\n\t\t\tstring.IsNullOrWhiteSpace( texture.AssetPath )\n\t\t\t\t? texture.OriginalPath\n\t\t\t\t: texture.AssetPath );\n\t\tif ( !SupportedImageExtensions.Contains( extension ) )\n\t\t\textension = \".png\";\n\t\treturn $\"{slug}_{binding.OutputName}_{ChannelSuffix( texture.Channel )}\"\n\t\t\t+ extension.ToLowerInvariant();\n\t}\n\n\tprivate static string WriteVmat(\n\t\tIReadOnlyDictionary<WeaponTextureChannel, string> textures )\n\t{\n\t\tstring TexturePath( WeaponTextureChannel channel, string fallback ) =>\n\t\t\ttextures.TryGetValue( channel, out var path )\n\t\t\t\t? path.Replace( '\\\\', '/' )\n\t\t\t\t: fallback;\n\t\tvar metalness = textures.TryGetValue(\n\t\t\tWeaponTextureChannel.Metalness,\n\t\t\tout var metalnessPath )\n\t\t\t\t? $$\"\"\"\n\n\t\t\t\t\t\tF_METALNESS_TEXTURE 1\n\t\t\t\t\t\tTextureMetalness \"{{metalnessPath.Replace( '\\\\', '/' )}}\"\n\t\t\t\t\t\"\"\"\n\t\t\t\t: \"\";\n\n\t\treturn $$\"\"\"\n\t\t\t// SboxWeaponAnimator generated material.\n\t\t\tLayer0\n\t\t\t{\n\t\t\t\tshader \"shaders/complex.shader\"\n\n\t\t\t\tF_SPECULAR 1\n\t\t\t\tTextureAmbientOcclusion \"{{TexturePath( WeaponTextureChannel.AmbientOcclusion, \"materials/default/default_ao.tga\" )}}\"\n\t\t\t\tTextureColor \"{{TexturePath( WeaponTextureChannel.BaseColor, \"materials/default/default_color.tga\" )}}\"\n\t\t\t\tTextureNormal \"{{TexturePath( WeaponTextureChannel.Normal, \"materials/default/default_normal.tga\" )}}\"\n\t\t\t\tTextureRoughness \"{{TexturePath( WeaponTextureChannel.Roughness, \"materials/default/default_rough.tga\" )}}\"{{metalness}}\n\t\t\t\tg_flModelTintAmount \"1.000\"\n\t\t\t\tg_vColorTint \"[1.000000 1.000000 1.000000 0.000000]\"\n\t\t\t\tg_flRoughnessScaleFactor \"1.000\"\n\t\t\t\tg_bFogEnabled \"1\"\n\t\t\t}\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string NormalizeMaterialPath( string value )\n\t{\n\t\tvar normalized = value.Replace( '\\\\', '/' ).Trim();\n\t\tif ( !Path.HasExtension( normalized ) )\n\t\t\tnormalized += \".vmat\";\n\t\treturn normalized;\n\t}\n\n\tinternal static string StoredMaterialSlot( string value )\n\t{\n\t\tvar normalized = NormalizeMaterialPath( value );\n\t\treturn normalized.EndsWith( \".vmat\", StringComparison.OrdinalIgnoreCase )\n\t\t\t? normalized[..^5]\n\t\t\t: normalized;\n\t}\n\n\tprivate static string ResourceMaterialSlot( string value ) =>\n\t\tNormalizeMaterialPath( value );\n\n\tprivate static bool IsIgnoredMaterialPath( string path ) =>\n\t\tpath.Equals( \"materials/default.vmat\", StringComparison.OrdinalIgnoreCase )\n\t\t|| path.Equals( \"materials/error.vmat\", StringComparison.OrdinalIgnoreCase )\n\t\t|| path.Equals(\n\t\t\t\"materials/tools/toolsinvisible.vmat\",\n\t\t\tStringComparison.OrdinalIgnoreCase );\n\n\tprivate static string LegalPreviewCacheRoot( string cacheRoot )\n\t{\n\t\treturn Path.Combine(\n\t\t\tWeaponSourceImporter.GetContentRoot(),\n\t\t\tLegalPreviewRelativeRootForTests( cacheRoot ).Replace(\n\t\t\t\t'/',\n\t\t\t\tPath.DirectorySeparatorChar ) );\n\t}\n\n\tprivate static string NormalizeSeparators( string value )\n\t{\n\t\tvar builder = new StringBuilder( value.Length );\n\t\tvar previousSeparator = false;\n\t\tforeach ( var character in value.ToLowerInvariant() )\n\t\t{\n\t\t\tvar separator = !char.IsLetterOrDigit( character );\n\t\t\tif ( separator )\n\t\t\t{\n\t\t\t\tif ( !previousSeparator )\n\t\t\t\t\tbuilder.Append( '_' );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tbuilder.Append( character );\n\t\t\t}\n\t\t\tpreviousSeparator = separator;\n\t\t}\n\t\treturn builder.ToString().Trim( '_' );\n\t}\n\n\tprivate static string NormalizeName( string value ) =>\n\t\tnew( value\n\t\t\t.Where( char.IsLetterOrDigit )\n\t\t\t.Select( char.ToLowerInvariant )\n\t\t\t.ToArray() );\n\n\tprivate static string UniqueOutputName(\n\t\tstring baseName,\n\t\tHashSet<string> usedNames )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( baseName ) )\n\t\t\tbaseName = \"material\";\n\t\tvar candidate = baseName;\n\t\tvar suffix = 2;\n\t\twhile ( !usedNames.Add( candidate ) )\n\t\t\tcandidate = $\"{baseName}_{suffix++}\";\n\t\treturn candidate;\n\t}\n\n\tprivate static string ChannelSuffix( WeaponTextureChannel channel ) => channel switch\n\t{\n\t\tWeaponTextureChannel.BaseColor => \"color\",\n\t\tWeaponTextureChannel.Normal => \"normal\",\n\t\tWeaponTextureChannel.Roughness => \"roughness\",\n\t\tWeaponTextureChannel.Metalness => \"metalness\",\n\t\tWeaponTextureChannel.AmbientOcclusion => \"ao\",\n\t\t_ => \"orm\"\n\t};\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Tests/WeaponAnimatorSelfTests.cs",
            "FileName": "WeaponAnimatorSelfTests.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic sealed class WeaponAnimatorSelfTestReport\n{\n\tpublic int Passed { get; internal set; }\n\tpublic List<string> Failures { get; } = [];\n\tpublic bool Success => Failures.Count == 0;\n\n\tpublic override string ToString() => Success\n\t\t? $\"Weapon Animator self-tests passed ({Passed} checks).\"\n\t\t: $\"Weapon Animator self-tests failed ({Failures.Count} failures, {Passed} checks passed):\\n\" +\n\t\t\tstring.Join( \"\\n\", Failures.Select( x => $\"  \u2022 {x}\" ) );\n}\n\npublic static class WeaponAnimatorSelfTests\n{\n\tpublic static WeaponAnimatorSelfTestReport RunAll()\n\t{\n\t\tvar report = new WeaponAnimatorSelfTestReport();\n\t\tRun( report, \"document roles\", TestDocumentRoles );\n\t\tRun( report, \"custom clip management and document title\", TestCustomClipManagement );\n\t\tRun( report, \"scale and units\", TestScaleAndUnits );\n\t\tRun( report, \"anchor lifecycle\", TestAnchorLifecycle );\n\t\tRun( report, \"default grip binding\", TestDefaultGripBinding );\n\t\tRun( report, \"weapon subtree filtering\", TestWeaponSubtreeFiltering );\n\t\tRun( report, \"rig browser grouping\", TestRigBrowserGrouping );\n\t\tRun( report, \"bind pose parity\", TestBindPoseParity );\n\t\tRun( report, \"neutral arm binding\", TestNeutralArmBinding );\n\t\tRun( report, \"generated Idle recovery\", TestGeneratedIdleRecovery );\n\t\tRun( report, \"selection field isolation\", TestSelectionFieldIsolation );\n\t\tRun( report, \"working pose and auto-key\", TestWorkingPose );\n\t\tRun( report, \"stepped part visibility\", TestPartVisibility );\n\t\tRun( report, \"schema migration\", TestSchemaMigration );\n\t\tRun( report, \"content-sized buttons\", TestContentSizedButtons );\n\t\tRun( report, \"alignment\", TestAlignment );\n\t\tRun( report, \"track interpolation\", TestInterpolation );\n\t\tRun( report, \"curve editor v2\", TestCurveEditorV2 );\n\t\tRun( report, \"frame snapping\", TestFrameSnapping );\n\t\tRun( report, \"timeline navigation\", TestTimelineNavigation );\n\t\tRun( report, \"timeline selection and movement\", TestTimelineSelectionAndMovement );\n\t\tRun( report, \"timeline key reversal\", TestTimelineKeyReversal );\n\t\tRun( report, \"timeline playback\", TestTimelinePlayback );\n\t\tRun( report, \"two-bone IK\", TestTwoBoneIk );\n\t\tRun( report, \"IK descendant propagation\", TestIkDescendantPropagation );\n\t\tRun( report, \"timed constraints before IK\", TestConstraintDrivenIk );\n\t\tRun( report, \"constraint maintained offset\", TestConstraintMaintainedOffset );\n\t\tRun( report, \"history and key clipboard\", TestControllerHistoryAndClipboard );\n\t\tRun( report, \"host skeleton cache invalidation\", TestHostSkeletonCache );\n\t\tRun( report, \"calibration and generation validation\", TestValidation );\n\t\tRun( report, \"generation output paths\", TestGenerationOutputPaths );\n\t\tRun( report, \"material discovery and output\", TestMaterialPipeline );\n\t\tRun( report, \"generated file removal\", TestGeneratedFileRemoval );\n\t\tRun( report, \"calibration rebase\", TestRebase );\n\t\tRun( report, \"DMX output\", TestDmxOutput );\n\t\tRun( report, \"filtered source wrapper\", TestFilteredSourceWrapper );\n\t\tRun( report, \"generation source adapters\", TestGenerationSourceAdapters );\n\t\tRun( report, \"deterministic generated text\", TestDeterministicOutput );\n\t\tRun( report, \"AnimGraph tags and fallbacks\", TestAnimGraphTagsAndFallbacks );\n\t\treturn report;\n\t}\n\n\t[Menu( \"Editor\", \"Tools/Weapon Animator/Run Self Tests\", \"science\" )]\n\tpublic static void RunFromEditor()\n\t{\n\t\tvar report = RunAll();\n\t\tif ( report.Success )\n\t\t\tLog.Info( $\"[Weapon Animator] {report}\" );\n\t\telse\n\t\t\tLog.Error( $\"[Weapon Animator] {report}\" );\n\t}\n\n\tprivate static void TestDocumentRoles( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Test Rifle\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tWeaponAnimationDocument.StandardClips().Count,\n\t\t\tdocument.Clips.Count,\n\t\t\t\"Default document must contain every standard slot.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tWeaponClipRole.Idle,\n\t\t\tdocument.GetSelectedClip()!.Role,\n\t\t\t\"Idle must be selected in a new document.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Workspace.ShowGuides,\n\t\t\t\"Viewport guides must be opt-in for new projects.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Workspace.FreeLookCamera,\n\t\t\t\"New projects must open with the familiar orbit camera.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Workspace.FullBrightViewport,\n\t\t\t\"New projects must open with lit viewport rendering.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.RimLightEnabled,\n\t\t\t\"The cyan viewport edge light must remain available by default.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t4.0f,\n\t\t\tdocument.Workspace.RimLightIntensity,\n\t\t\t0.0001f,\n\t\t\t\"The edge light default must be restrained rather than the old over-bright value.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t1.0f,\n\t\t\tdocument.Workspace.CameraMoveSpeed,\n\t\t\t0.0001f,\n\t\t\t\"The free-look camera must start at normal movement speed.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.SnapRotation,\n\t\t\t\"Rotation snapping must be enabled in new projects.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t15.0f,\n\t\t\tdocument.Workspace.RotationSnapDegrees,\n\t\t\t0.0001f,\n\t\t\t\"Rotation snapping must start at the familiar 15-degree step.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t30.0f,\n\t\t\tWeaponAnimatorViewport.AdjustRotationSnapAngle( 15.0f, 1 ),\n\t\t\t0.0001f,\n\t\t\t\"The snap-angle stepper must advance through the standard angle presets.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t5.0f,\n\t\t\tWeaponAnimatorViewport.AdjustRotationSnapAngle( 15.0f, -1 ),\n\t\t\t0.0001f,\n\t\t\t\"The snap-angle stepper must move backward through the standard angle presets.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t0.25f,\n\t\t\tWeaponAnimatorViewport.AdjustRotationSnapAngle( 0.25f, -1 ),\n\t\t\t0.0001f,\n\t\t\t\"The snap-angle stepper must retain its lower bound.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t180.0f,\n\t\t\tWeaponAnimatorViewport.AdjustRotationSnapAngle( 180.0f, 1 ),\n\t\t\t0.0001f,\n\t\t\t\"The snap-angle stepper must retain its upper bound.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t1.25f,\n\t\t\tWeaponAnimatorViewport.AdjustCameraSpeed( 1.0f, 1 ),\n\t\t\t0.0001f,\n\t\t\t\"Free-look wheel-up must increase low movement speeds in fine steps.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t0.75f,\n\t\t\tWeaponAnimatorViewport.AdjustCameraSpeed( 1.0f, -1 ),\n\t\t\t0.0001f,\n\t\t\t\"Free-look wheel-down must decrease low movement speeds in fine steps.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t100.0f,\n\t\t\tWeaponAnimatorViewport.AdjustCameraSpeed( 100.0f, 1 ),\n\t\t\t0.0001f,\n\t\t\t\"Free-look movement speed must remain within its upper bound.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t0.25f,\n\t\t\tWeaponAnimatorViewport.AdjustCameraSpeed( 0.25f, -1 ),\n\t\t\t0.0001f,\n\t\t\t\"Free-look movement speed must remain within its lower bound.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t0.10f,\n\t\t\tdocument.Workspace.GridOpacity,\n\t\t\t0.0001f,\n\t\t\t\"The default viewport grid must be substantially quieter than the editor grid.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t0.65f,\n\t\t\tdocument.Workspace.GridLineThickness,\n\t\t\t0.0001f,\n\t\t\t\"The default viewport grid must use fine lines.\" );\n\t\tvar gridStyle = GridVisualStyle.Resolve(\n\t\t\tdocument.Workspace.GridOpacity,\n\t\t\tdocument.Workspace.GridLineThickness );\n\t\tNear(\n\t\t\treport,\n\t\t\tdocument.Workspace.GridOpacity,\n\t\t\tgridStyle.AxisOpacity,\n\t\t\t0.0001f,\n\t\t\t\"The opacity preference must affect the colored origin axes.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgridStyle.AxisWidth < 1\n\t\t\t\t&& gridStyle.AxisWidth > gridStyle.MajorWidth\n\t\t\t\t&& gridStyle.MajorWidth > gridStyle.MinorWidth,\n\t\t\t\"The line-weight preference must allow thin axes while preserving grid hierarchy.\" );\n\t\tvar faintStyle = GridVisualStyle.Resolve( 0.02f, 0.1f );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfaintStyle.AxisOpacity < gridStyle.AxisOpacity\n\t\t\t\t&& faintStyle.AxisWidth < gridStyle.AxisWidth,\n\t\t\t\"Lower opacity and weight must visibly affect both primary and secondary grid lines.\" );\n\t\tvar rimStyle = ViewportRimLightStyle.Resolve(\n\t\t\tdocument.Workspace.RimLightEnabled,\n\t\t\tdocument.Workspace.RimLightIntensity,\n\t\t\tfalse );\n\t\tCheck(\n\t\t\treport,\n\t\t\trimStyle.Enabled,\n\t\t\t\"The edge-light preference must enable the cyan point light in lit mode.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t4.0f,\n\t\t\trimStyle.Intensity,\n\t\t\t0.0001f,\n\t\t\t\"The viewport must apply the persisted edge-light brightness.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ViewportRimLightStyle.Resolve( true, 4, true ).Enabled\n\t\t\t\t&& !ViewportRimLightStyle.Resolve( false, 4, false ).Enabled,\n\t\t\t\"Full Bright and the explicit toggle must both disable the edge light.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t12,\n\t\t\tViewportRimLightStyle.Resolve( true, 99, false ).Intensity,\n\t\t\t0.0001f,\n\t\t\t\"Edge-light brightness must remain inside its supported range.\" );\n\t\tvar fullBrightArms = ArmPreviewVisualStyle.Resolve(\n\t\t\tWeaponAnimatorStage.Animate,\n\t\t\ttrue );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfullBrightArms.UseFlatMaterial\n\t\t\t\t&& MathF.Max(\n\t\t\t\t\tfullBrightArms.Tint.r,\n\t\t\t\t\tMathF.Max( fullBrightArms.Tint.g, fullBrightArms.Tint.b ) ) > 0.1f,\n\t\t\t\"Full Bright must use a visible neutral arms material instead of rendering skin black.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ArmPreviewVisualStyle.Resolve( WeaponAnimatorStage.Animate, false ).UseFlatMaterial,\n\t\t\t\"Lit animation preview must preserve the production arms materials.\" );\n\t\t// The four *_ikrule names are the real helper bones on the Facepunch arms; ik_hand_* are\n\t\t// added by HostSkeletonBuilder. None are read by anything, and all trail long lines.\n\t\tforeach ( var ikName in new[]\n\t\t{\n\t\t\t\"hand_R_to_L_ikrule\",\n\t\t\t\"hand_L_to_R_ikrule\",\n\t\t\t\"hand_R_to_weapon_ikrule\",\n\t\t\t\"hand_L_to_weapon_ikrule\",\n\t\t\t\"ik_hand_R\",\n\t\t\t\"ik_hand_L\",\n\t\t\t\"weapon_IK_hand_R\",\n\t\t\t\"weapon_IK_hand_L\"\n\t\t} )\n\t\t{\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tSkeletonBoneStyle.Classify( new HostBone { Name = ikName } ) == SkeletonBoneKind.Ik,\n\t\t\t\t$\"{ikName} must be treated as an IK helper bone.\" );\n\t\t}\n\t\t// Weapon rigs ship their own IK targets, so the IK test deliberately wins over IsWeaponBone.\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonBoneStyle.Classify(\n\t\t\t\tnew HostBone { Name = \"weapon_IK_hand_R\", IsWeaponBone = true } )\n\t\t\t\t\t== SkeletonBoneKind.Ik,\n\t\t\t\"An IK target from the weapon rig must be treated as an IK helper, not a weapon bone.\" );\n\t\t// The trap in letting IK win: \"ik\" must match as a token, never as a substring.\n\t\tforeach ( var keptName in new[]\n\t\t{\n\t\t\t\"weapon_root\",\n\t\t\t\"spike_guard\",\n\t\t\t\"strike_plate\",\n\t\t\t\"trigger\",\n\t\t\t\"slide_kick\",\n\t\t\t\"ikon\"\n\t\t} )\n\t\t{\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tSkeletonBoneStyle.Classify(\n\t\t\t\t\tnew HostBone { Name = keptName, IsWeaponBone = true } )\n\t\t\t\t\t\t== SkeletonBoneKind.Weapon,\n\t\t\t\t$\"{keptName} must stay a visible weapon bone - 'ik' matches tokens, not substrings.\" );\n\t\t}\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonBoneStyle.Classify( new HostBone { Name = \"arm_lower_R_twist1\" } )\n\t\t\t\t\t== SkeletonBoneKind.Twist\n\t\t\t\t&& SkeletonBoneStyle.Classify( new HostBone { Name = \"arm_lower_R_twistctrl0\" } )\n\t\t\t\t\t== SkeletonBoneKind.Twist\n\t\t\t\t&& SkeletonBoneStyle.Classify( new HostBone { Name = \"hand_R\" } )\n\t\t\t\t\t== SkeletonBoneKind.Arm,\n\t\t\t\"Twist helpers must be distinguished from the arm chain proper.\" );\n\t\tvar hiddenIk = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Ik, 2, 8, false );\n\t\tvar shownIk = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Ik, 2, 8, true );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!hiddenIk.Visible && shownIk.Visible && shownIk.Color == WeaponAnimatorTheme.Coral,\n\t\t\t\"IK bones must be hidden by default and drawn red when enabled.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ).Visible\n\t\t\t\t&& SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Visible,\n\t\t\t\"Hiding IK bones must not hide anything else.\" );\n\t\tvar twistStyle = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Twist, 4, 8, false );\n\t\tvar armStyle = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 4, 8, false );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttwistStyle.Visible\n\t\t\t\t&& twistStyle.AlphaScale < armStyle.AlphaScale\n\t\t\t\t&& twistStyle.Color == armStyle.Color,\n\t\t\t\"Twist bones must recede without changing hue or becoming unclickable.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttwistStyle.Hollow\n\t\t\t\t&& shownIk.Hollow\n\t\t\t\t&& !armStyle.Hollow\n\t\t\t\t&& !SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Hollow,\n\t\t\t\"Derived bones must be hollow and directly posed bones solid, so shape carries the distinction.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 6, 8, false ).Color\n\t\t\t\t== WeaponAnimatorTheme.Amber,\n\t\t\t\"Weapon bones must stay amber regardless of depth.\" );\n\t\tvar rootColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 0, 8, false ).Color;\n\t\tvar midColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 4, 8, false ).Color;\n\t\tvar tipColor = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 8, 8, false ).Color;\n\t\tCheck(\n\t\t\treport,\n\t\t\trootColor != midColor && midColor != tipColor && rootColor != tipColor,\n\t\t\t\"The arm gradient must separate root, mid-chain and fingertip bones.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttipColor.r > rootColor.r && tipColor.g > rootColor.g,\n\t\t\t\"The arm gradient must brighten toward the fingertips.\" );\n\n\t\t// The first ramp faded to near-white at the fingertips, where bones are densest, and the\n\t\t// distal steps were hard to tell apart. Guard the weakest step, and specifically require the\n\t\t// distal half to separate about as well as the proximal half.\n\t\tstatic float Separation( int fromDepth, int toDepth )\n\t\t{\n\t\t\tvar a = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, fromDepth, 8, false ).Color;\n\t\t\tvar b = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, toDepth, 8, false ).Color;\n\t\t\treturn MathF.Sqrt(\n\t\t\t\t((a.r - b.r) * (a.r - b.r))\n\t\t\t\t+ ((a.g - b.g) * (a.g - b.g))\n\t\t\t\t+ ((a.b - b.b) * (a.b - b.b)) );\n\t\t}\n\n\t\tvar weakestStep = float.MaxValue;\n\t\tfor ( var depth = 0; depth < 8; depth++ )\n\t\t\tweakestStep = MathF.Min( weakestStep, Separation( depth, depth + 1 ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\tweakestStep > 0.15f,\n\t\t\t\"Every step along the arm gradient must be clearly distinguishable from the next.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSeparation( 0, 8 ) > 1.0f,\n\t\t\t\"The gradient must travel a long way between the root and the fingertips.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimatorTheme.BoneDepthColor( -5 ) == WeaponAnimatorTheme.BoneDepthColor( 0 )\n\t\t\t\t&& WeaponAnimatorTheme.BoneDepthColor( 5 ) == WeaponAnimatorTheme.BoneDepthColor( 1 )\n\t\t\t\t&& WeaponAnimatorTheme.BoneDepthColor( float.NaN )\n\t\t\t\t\t== WeaponAnimatorTheme.BoneDepthColor( 0 ),\n\t\t\t\"Out-of-range and non-finite depth fractions must clamp to the ramp ends.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 3, 0, false ).Color\n\t\t\t\t== WeaponAnimatorTheme.BoneDepthColor( 0 ),\n\t\t\t\"A skeleton with no measurable depth must not divide by zero.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Workspace.ShowIkBones,\n\t\t\t\"IK bones must be hidden by default.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.BoneOcclusionEnabled,\n\t\t\t\"Dynamic bone occlusion must be enabled by default.\" );\n\n\t\t// Occluded bones must read as a different category, not just a dimmer copy: hue carries\n\t\t// depth along the arm, so draining it is what makes \"behind something\" legible.\n\t\tstatic float Saturation( Color color )\n\t\t{\n\t\t\tvar max = MathF.Max( color.r, MathF.Max( color.g, color.b ) );\n\t\t\tvar min = MathF.Min( color.r, MathF.Min( color.g, color.b ) );\n\t\t\treturn max - min;\n\t\t}\n\n\t\tvar vividBone = SkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 8, 8, false ).Color;\n\t\tvar occludedBone = SkeletonOverlayStyle.Occlude( vividBone );\n\t\tvar gradientOverlay = SkeletonOverlayStyle.Resolve( true, 1.0f );\n\t\tvar visibleLine = gradientOverlay.ResolveLineVisual(\n\t\t\tSkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 1, 8, false ),\n\t\t\tfalse );\n\t\tvar hiddenLine = gradientOverlay.ResolveLineVisual(\n\t\t\tSkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ),\n\t\t\ttrue );\n\t\tvar middleLine = SkeletonLineVisual.Lerp( visibleLine, hiddenLine, 0.5f );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSaturation( occludedBone ) < Saturation( vividBone ) * 0.35f,\n\t\t\t\"Occluded bones must lose most of their colour so they stop competing for attention.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSaturation( occludedBone ) > 0.001f,\n\t\t\t\"Occluded bones must keep a trace of colour so weapon and arm stay tellable apart.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOverlayStyle.Occlude( Color.White.WithAlpha( 0.4f ) ).a == 0.4f,\n\t\t\t\"Draining colour must not disturb the alpha the occluded pass already applies.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOverlayStyle.OccludedDotScale < 1.0f\n\t\t\t\t&& SkeletonOverlayStyle.OccludedLineThickness < 1.0f,\n\t\t\t\"Occluded bones must draw smaller so they do not veil bones in front.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tmiddleLine.Thickness < visibleLine.Thickness\n\t\t\t\t&& middleLine.Thickness > hiddenLine.Thickness\n\t\t\t\t&& middleLine.Color.a < visibleLine.Color.a\n\t\t\t\t&& middleLine.Color.a > hiddenLine.Color.a\n\t\t\t\t&& middleLine.Color != visibleLine.Color\n\t\t\t\t&& middleLine.Color != hiddenLine.Color,\n\t\t\t\"A mixed-visibility connection must gradient its colour, opacity, and width.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOverlayStyle.OcclusionDepthClearance( 80 )\n\t\t\t\t> SkeletonOverlayStyle.OcclusionDepthClearance( 10 )\n\t\t\t\t&& SkeletonOverlayStyle.OcclusionDepthClearance( float.NaN ) > 0,\n\t\t\t\"Occlusion clearance must follow marker size and remain valid for bad camera distances.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 80.015f )\n\t\t\t\t&& !SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.95f )\n\t\t\t\t&& SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.0f ),\n\t\t\t\"A surface at the bone endpoint must remain visible while a nearer surface occludes it.\" );\n\n\t\tvar xrayStyle = SkeletonOverlayStyle.Resolve( true, 1.0f );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.XRaySkeleton,\n\t\t\t\"Bones hidden behind the arms must be visible by default.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\txrayStyle.DrawThroughMeshes\n\t\t\t\t&& xrayStyle.OccludedAlpha > 0\n\t\t\t\t&& xrayStyle.OccludedAlpha < 1.0f,\n\t\t\t\"Occluded bones must stay visible but subordinate to unoccluded ones.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SkeletonOverlayStyle.Resolve( false, 1.0f ).DrawThroughMeshes,\n\t\t\t\"Disabling x-ray must restore the depth-tested skeleton overlay.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SkeletonOverlayStyle.Resolve( true, 0 ).DrawThroughMeshes,\n\t\t\t\"A fully faded skeleton must not draw through viewport meshes.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOverlayStyle.Resolve( true, 0.18f ).OccludedAlpha < xrayStyle.OccludedAlpha,\n\t\t\t\"Fainter skeleton passes must produce proportionally fainter ghosts.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\txrayStyle.OccludedAlpha,\n\t\t\tSkeletonOverlayStyle.Resolve( true, 99.0f ).OccludedAlpha,\n\t\t\t0.0001f,\n\t\t\t\"Overlay alpha must remain inside its supported range.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\txrayStyle.OccludedAlpha,\n\t\t\tSkeletonOverlayStyle.Resolve( true, float.NaN ).OccludedAlpha,\n\t\t\t0.0001f,\n\t\t\t\"A non-finite overlay alpha must fall back to the default.\" );\n\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SkeletonOcclusionPolicy.IsOccludedByArm(\n\t\t\t\tfalse,\n\t\t\t\t1,\n\t\t\t\t1 )\n\t\t\t\t&& SkeletonOcclusionPolicy.IsOccludedByArm(\n\t\t\t\t\tfalse,\n\t\t\t\t\t1,\n\t\t\t\t\t-1 ),\n\t\t\t\"Each finger must ignore its own hand mesh and reduce only behind the opposite hand.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOcclusionPolicy.IsOccludedByArm(\n\t\t\t\ttrue,\n\t\t\t\t0,\n\t\t\t\t1 ),\n\t\t\t\"Weapon bones must reduce only when an arm is actually in front.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SkeletonOcclusionPolicy.IsOccludedByArm(\n\t\t\t\tfalse,\n\t\t\t\t-1,\n\t\t\t\t0 )\n\t\t\t\t&& !SkeletonOcclusionPolicy.IsOccludedByArm(\n\t\t\t\t\tfalse,\n\t\t\t\t\t-1,\n\t\t\t\t\t-1 ),\n\t\t\t\"Unowned and same-side surfaces must never reduce an arm bone.\" );\n\n\t\tvar first = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tvar second = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tfirst.Name = second.Name = \"Mechanical Check\";\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationNames.SequenceName( first ) != WeaponAnimationNames.SequenceName( second ),\n\t\t\t\"Custom sequence names must remain unique.\" );\n\t\tdocument.Clips.Add( first );\n\t\tdocument.Clips.Add( second );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationNames.RepairCustomSequenceNames( document )\n\t\t\t\t&& !first.GeneratedSequenceName.Contains( first.Id.ToString( \"N\" ), StringComparison.Ordinal )\n\t\t\t\t&& !second.GeneratedSequenceName.Contains( second.Id.ToString( \"N\" ), StringComparison.Ordinal )\n\t\t\t\t&& first.GeneratedSequenceName != second.GeneratedSequenceName,\n\t\t\t\"Custom clips must receive stable, readable sequence names with short collision suffixes.\" );\n\t\tvar customSequence = first.GeneratedSequenceName;\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimationNames.RepairCustomSequenceNames( document )\n\t\t\t\t&& first.GeneratedSequenceName == customSequence,\n\t\t\t\"Resolved custom sequence names must remain stable across later repairs.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tCalibrationSelection.TryGetAnchor(\n\t\t\t\tCalibrationSelection.Anchor( AnchorKind.Muzzle ),\n\t\t\t\tout var anchorKind )\n\t\t\t\t&& anchorKind == AnchorKind.Muzzle,\n\t\t\t\"Calibration anchor control names must round-trip.\" );\n\n\t\tvar muzzleAnchor = new WeaponAnchor { Kind = AnchorKind.Muzzle, Name = \"Muzzle\" };\n\t\tvar customA = new WeaponAnchor { Kind = AnchorKind.Custom, Name = \"Suppressor Mount\" };\n\t\tvar customB = new WeaponAnchor { Kind = AnchorKind.Custom, Name = \"Suppressor Mount\" };\n\t\tdocument.Calibration.Anchors.Add( muzzleAnchor );\n\t\tdocument.Calibration.Anchors.Add( customA );\n\t\tdocument.Calibration.Anchors.Add( customB );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationNames.RepairCustomAnchorNames( document )\n\t\t\t\t&& customA.GeneratedAttachmentName == \"suppressor_mount\"\n\t\t\t\t&& customB.GeneratedAttachmentName != customA.GeneratedAttachmentName,\n\t\t\t\"Custom anchors must take readable attachment names and separate on collision.\" );\n\t\tvar resolvedAnchor = customA.GeneratedAttachmentName;\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimationNames.RepairCustomAnchorNames( document )\n\t\t\t\t&& customA.GeneratedAttachmentName == resolvedAnchor,\n\t\t\t\"Resolved custom attachment names must stay stable across later repairs.\" );\n\t\tcustomA.Name = \"Silencer Mount\";\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimationNames.RepairCustomAnchorNames( document )\n\t\t\t\t&& WeaponAnimationNames.AttachmentName( customA ) == resolvedAnchor,\n\t\t\t\"Renaming a custom anchor must not silently rename the generated attachment.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationNames.AttachmentName( muzzleAnchor ) == \"muzzle\",\n\t\t\t\"Fixed anchor kinds must keep their reserved attachment names.\" );\n\t\tvar reservedClash = new WeaponAnchor { Kind = AnchorKind.Custom, Name = \"Muzzle\" };\n\t\tdocument.Calibration.Anchors.Add( reservedClash );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationNames.RepairCustomAnchorNames( document )\n\t\t\t\t&& reservedClash.GeneratedAttachmentName != \"muzzle\",\n\t\t\t\"A custom anchor must not claim an attachment name reserved by a fixed kind.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tCalibrationSelection.TryGetCustomAnchorId(\n\t\t\t\tCalibrationSelection.Anchor( customA ),\n\t\t\t\tout var customAnchorId )\n\t\t\t\t&& customAnchorId == customA.Id\n\t\t\t\t&& CalibrationSelection.Resolve(\n\t\t\t\t\tdocument,\n\t\t\t\t\tCalibrationSelection.Anchor( customB ) ) == customB,\n\t\t\t\"Custom anchor selection tokens must round-trip to the individual anchor.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!CalibrationSelection.TryGetCustomAnchorId(\n\t\t\t\tCalibrationSelection.Anchor( AnchorKind.Muzzle ),\n\t\t\t\tout _ )\n\t\t\t\t&& CalibrationSelection.TryGetAnchor(\n\t\t\t\t\tCalibrationSelection.Anchor( customA ),\n\t\t\t\t\tout var customKind )\n\t\t\t\t&& customKind == AnchorKind.Custom,\n\t\t\t\"Fixed anchor tokens must carry no id, and custom tokens must still report their kind.\" );\n\t\tdocument.Calibration.Anchors.Clear();\n\n\t\t// A .wepanim created outside the New Project flow arrives carrying CreateDefault()'s\n\t\t// \"New Weapon\", which used to generate every project into weapons/new_weapon.\n\t\tvar named = WeaponAnimationDocument.CreateDefault();\n\t\tCheck(\n\t\t\treport,\n\t\t\tnamed.Output.AssetName == \"new_weapon\",\n\t\t\t\"The default document must still carry the documented placeholder asset name.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimatorWindow.AdoptAssetFileName( named, \"weapons/test2.wepanim\" )\n\t\t\t\t&& named.Name == \"test2\"\n\t\t\t\t&& named.Output.AssetName == \"test2\"\n\t\t\t\t&& named.Output.GetDefaultRelativeFolder() == \"weapons/test2/viewmodel\",\n\t\t\t\"Opening a project must adopt its filename for generated names and folders.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimatorWindow.AdoptAssetFileName( named, \"weapons/test2.wepanim\" ),\n\t\t\t\"Adopting an unchanged filename must not mark the document dirty.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimatorWindow.AdoptAssetFileName( named, \"weapons/AK 74.wepanim\" )\n\t\t\t\t&& named.Name == \"AK 74\"\n\t\t\t\t&& named.Output.AssetName == \"ak_74\",\n\t\t\t\"Save As must rename generated output, slugifying the display name.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimatorWindow.AdoptAssetFileName( named, \"\" )\n\t\t\t\t&& !WeaponAnimatorWindow.AdoptAssetFileName( named, (string?)null )\n\t\t\t\t&& named.Output.AssetName == \"ak_74\",\n\t\t\t\"An unsaved project must keep its existing generated name.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"Alignment marker \u2014 rear\",\n\t\t\tCalibrationSelection.DisplayName( AnchorKind.RearBore ),\n\t\t\t\"Auto-align markers must use purpose-driven names.\" );\n\n\t\tdocument.Workspace.AnimationRightSplitterState = \"right-column-layout\";\n\t\tvar reopened = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )!;\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"right-column-layout\",\n\t\t\treopened.Workspace.AnimationRightSplitterState,\n\t\t\t\"The selected-control and clip-rack splitter must persist with the workspace.\" );\n\t}\n\n\tprivate static void TestCustomClipManagement(\n\t\tWeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Internal Name\" );\n\t\tvar first = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tfirst.Name = \"Mechanical Check\";\n\t\tvar second = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tsecond.Name = \"Mechanical Check\";\n\t\tdocument.Clips.Add( first );\n\t\tdocument.Clips.Add( second );\n\t\tWeaponAnimationNames.RepairCustomSequenceNames( document );\n\t\tdocument.Workspace.SelectedClipId = first.Id;\n\t\tdocument.Workspace.WorkingPoseOverrides.Add( new WorkingPoseOverride\n\t\t{\n\t\t\tClipId = first.Id,\n\t\t\tTarget = \"weapon_root\"\n\t\t} );\n\t\tdocument.Workspace.TimelineViews.Add( new TimelineViewState\n\t\t{\n\t\t\tClipId = first.Id\n\t\t} );\n\t\tdocument.Workspace.CurveViews.Add( new CurveViewState\n\t\t{\n\t\t\tClipId = first.Id\n\t\t} );\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tcontroller.RenameCustomClip( first.Id, \"Safety Check\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"Safety Check\",\n\t\t\tfirst.Name,\n\t\t\t\"Custom clips must be renameable.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"safety_check\",\n\t\t\tfirst.GeneratedSequenceName,\n\t\t\t\"Renaming a custom clip must assign a readable collision-safe sequence name.\" );\n\t\tcontroller.Undo();\n\t\tvar restoredFirst = controller.Document.Clips.First( clip => clip.Id == first.Id );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"Mechanical Check\",\n\t\t\trestoredFirst.Name,\n\t\t\t\"Custom clip rename must be one undoable action.\" );\n\n\t\tcontroller.DeleteCustomClip( first.Id );\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.Document.Clips.All( clip => clip.Id != first.Id )\n\t\t\t\t&& controller.Document.Workspace.WorkingPoseOverrides.All(\n\t\t\t\t\titem => item.ClipId != first.Id )\n\t\t\t\t&& controller.Document.Workspace.TimelineViews.All(\n\t\t\t\t\titem => item.ClipId != first.Id )\n\t\t\t\t&& controller.Document.Workspace.CurveViews.All(\n\t\t\t\t\titem => item.ClipId != first.Id ),\n\t\t\t\"Deleting a custom clip must remove its clip-owned workspace state.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tWeaponClipRole.Idle,\n\t\t\tcontroller.Document.GetSelectedClip()!.Role,\n\t\t\t\"Deleting the selected custom clip must return selection to Idle.\" );\n\t\tcontroller.Undo();\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.Document.Clips.Any( clip => clip.Id == first.Id ),\n\t\t\t\"Custom clip deletion must restore the complete clip through one undo.\" );\n\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"S&box Weapon Animator \u2014 p30l.wepanim\",\n\t\t\tWeaponAnimatorWindow.ComposeWindowTitle(\n\t\t\t\t\"weapons/pistols/p30l.wepanim\",\n\t\t\t\t\"New Weapon\",\n\t\t\t\tfalse ),\n\t\t\t\"The window title must use the open asset filename instead of the stale document name.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"S&box Weapon Animator \u2014 p30l.wepanim *\",\n\t\t\tWeaponAnimatorWindow.ComposeWindowTitle(\n\t\t\t\t\"weapons/pistols/p30l.wepanim\",\n\t\t\t\t\"New Weapon\",\n\t\t\t\ttrue ),\n\t\t\t\"The filename caption must retain the dirty marker.\" );\n\t}\n\n\tprivate static void TestScaleAndUnits( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationMath.TryCalculateUniformScale(\n\t\t\t\tVector3.Zero,\n\t\t\t\tnew Vector3( 10, 0, 0 ),\n\t\t\t\t25.4f,\n\t\t\t\tMeasurementUnit.Centimetres,\n\t\t\t\tnew Vector3( 10, 4, 2 ),\n\t\t\t\tout var preview ),\n\t\t\t\"A valid metric measurement should calculate scale.\" );\n\t\tNear( report, 1, preview.UniformScale, 0.0001f, \"25.4 cm over 10 units should scale to one inch per unit.\" );\n\t\tNear( report, 25.4f, WeaponAnimationMath.ToCentimetres( 10 ), 0.0001f, \"Unit conversion must be exact.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimationMath.TryCalculateUniformScale(\n\t\t\t\tVector3.Zero,\n\t\t\t\tVector3.Zero,\n\t\t\t\t1,\n\t\t\t\tMeasurementUnit.Inches,\n\t\t\t\tVector3.One,\n\t\t\t\tout _ ),\n\t\t\t\"Coincident measurement points must be rejected.\" );\n\t}\n\n\tprivate static void TestAnchorLifecycle( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = ValidDocument();\n\t\tdocument.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 1, 2, 3 ) ) );\n\t\tdocument.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 4, 5, 6 ) ) );\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tdocument.Calibration.Anchors.Count( anchor => anchor.Kind == AnchorKind.Eject ),\n\t\t\t\"Repicking an anchor must replace it instead of creating an ambiguous duplicate.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 4, 5, 6 ),\n\t\t\tdocument.Calibration.GetAnchor( AnchorKind.Eject )!.LocalPosition,\n\t\t\t0.0001f,\n\t\t\t\"Repicking an anchor must update its editable position.\" );\n\n\t\tdocument.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == AnchorKind.Eject );\n\t\tCheck( report, document.Calibration.GetAnchor( AnchorKind.Eject ) is null, \"Optional anchors must be individually deletable.\" );\n\t\tdocument.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == AnchorKind.Grip );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,\n\t\t\t\"Deleting a required anchor must reopen its calibration requirement.\" );\n\t}\n\n\tprivate static void TestDefaultGripBinding( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Calibration.PhysicalTransform = new Transform( new Vector3( 10, 0, 0 ) );\n\t\tdocument.Calibration.FramingTransform = new Transform( new Vector3( 0, 2, 0 ) );\n\t\tdocument.Calibration.SetAnchor( Anchor( AnchorKind.Grip, new Vector3( 1, 0, 0 ) ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\tCalibrationBindingSeeder.SeedDefaultPrimaryHand( document ),\n\t\t\t\"A calibrated grip must seed the animation page's primary-hand target.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"weapon_root\",\n\t\t\tdocument.Binding.PrimaryHand.AttachedBone,\n\t\t\t\"The primary hand must default to the canonical weapon root attachment.\" );\n\t\tvar skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );\n\t\tvar primaryWorld = skeleton.ByName[\"weapon_root\"].BindModelTransform.PointToWorld(\n\t\t\tdocument.Binding.PrimaryHand.Transform.Position );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 11, 2, 0 ),\n\t\t\tprimaryWorld,\n\t\t\t0.0001f,\n\t\t\t\"The primary-hand target must include physical and viewmodel placement.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Binding.PrimaryHand.IsBound,\n\t\t\t\"Seeding the primary target must not enable IK before the user binds the hand.\" );\n\t}\n\n\tprivate static void TestWeaponSubtreeFiltering( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar rig = new WeaponRigDefinition\n\t\t{\n\t\t\tRootBone = \"weapon_root\",\n\t\t\tBones =\n\t\t\t[\n\t\t\t\tDefinition( \"weapon_root\", \"\", WeaponBoneClassification.WeaponRoot, Vector3.Zero ),\n\t\t\t\tDefinition( \"receiver\", \"weapon_root\", WeaponBoneClassification.Animatable, new Vector3( 1, 0, 0 ) ),\n\t\t\t\tDefinition( \"slide_any_name\", \"receiver\", WeaponBoneClassification.Animatable, new Vector3( 2, 0, 0 ) ),\n\t\t\t\tDefinition( \"foreign_branch_947\", \"weapon_root\", WeaponBoneClassification.Animatable, new Vector3( 0, 1, 0 ) ),\n\t\t\t\tDefinition( \"mystery_child\", \"foreign_branch_947\", WeaponBoneClassification.Animatable, new Vector3( 0, 2, 0 ) )\n\t\t\t]\n\t\t};\n\t\tWeaponRigHierarchy.RepairMetadata( rig, false );\n\t\tWeaponRigHierarchy.SelectWeaponSubtree( rig, \"weapon_root\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponRigHierarchy.ExcludeBranch( rig, \"foreign_branch_947\" ),\n\t\t\t\"An arbitrary foreign branch must be excludable without name heuristics.\" );\n\t\tWeaponRigHierarchy.ConfirmFilteredPreview( rig );\n\n\t\tCheck( report, rig.FindBone( \"receiver\" )!.Inclusion == WeaponBoneInclusion.Included, \"Weapon descendants must remain included.\" );\n\t\tCheck( report, rig.FindBone( \"mystery_child\" )!.Inclusion == WeaponBoneInclusion.Excluded, \"Excluding a branch must exclude every descendant.\" );\n\t\tCheck( report, !rig.ReviewRequired && rig.FilteredPreviewConfirmed, \"Confirming the filtered preview must close the rig-review gate.\" );\n\t\tvar auditSignature = RigAuditPanel.BoneStructureSignature( rig, \"\", true, true, false );\n\t\trig.ReviewRequired = true;\n\t\tEqual(\n\t\t\treport,\n\t\t\tauditSignature,\n\t\t\tRigAuditPanel.BoneStructureSignature( rig, \"\", true, true, false ),\n\t\t\t\"Non-structural document refreshes must not rebuild the rig-audit bone rows.\" );\n\t\trig.FindBone( \"receiver\" )!.Classification = WeaponBoneClassification.Structural;\n\t\tCheck(\n\t\t\treport,\n\t\t\tauditSignature != RigAuditPanel.BoneStructureSignature( rig, \"\", true, true, false ),\n\t\t\t\"Classification changes must rebuild the rig-audit bone rows.\" );\n\t\trig.FindBone( \"receiver\" )!.Classification = WeaponBoneClassification.Animatable;\n\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Rig = rig;\n\t\tvar skeleton = HostSkeletonBuilder.Build( document, false );\n\t\tCheck( report, skeleton.ByName.ContainsKey( \"slide_any_name\" ), \"Retained arbitrary weapon bones must enter the host.\" );\n\t\tCheck( report, !skeleton.ByName.ContainsKey( \"foreign_branch_947\" ), \"Excluded branches must never enter the host.\" );\n\t}\n\n\tprivate static void TestBindPoseParity( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Calibration.PhysicalTransform = new Transform(\n\t\t\tnew Vector3( 8, -3, 2 ),\n\t\t\tRotation.From( 12, 35, -7 ),\n\t\t\t0.6f );\n\t\tdocument.Calibration.FramingTransform = new Transform(\n\t\t\tnew Vector3( 1, 2, -0.5f ),\n\t\t\tRotation.From( -4, 8, 3 ) );\n\n\t\tvar rootModel = new Transform(\n\t\t\tnew Vector3( -2.4f, 0, 4.1f ),\n\t\t\tRotation.From( 0, 0, -90 ),\n\t\t\t1.0f );\n\t\tvar childLocal = new Transform(\n\t\t\tnew Vector3( 1.2f, -0.4f, 0.8f ),\n\t\t\tRotation.From( 0, 90, 0 ),\n\t\t\t1.0f );\n\t\tvar childModel = WeaponAnimationMath.Compose( rootModel, childLocal );\n\t\tdocument.Rig = new WeaponRigDefinition\n\t\t{\n\t\t\tRootBone = \"weapon_root\",\n\t\t\tBones =\n\t\t\t[\n\t\t\t\tDefinition( \"weapon_root\", \"\", WeaponBoneClassification.WeaponRoot, rootModel ),\n\t\t\t\tDefinition( \"rotated_part\", \"weapon_root\", WeaponBoneClassification.Animatable, childModel )\n\t\t\t],\n\t\t\tFilteredPreviewConfirmed = true\n\t\t};\n\t\tWeaponRigHierarchy.RepairMetadata( document.Rig, false );\n\n\t\tvar parity = HostSkeletonBuilder.ValidateBindParity( document, includeArmProfile: false );\n\t\tEqual( report, 0, parity.Count, \"Stage 2 must reproduce every Stage 1 weapon bind transform.\" );\n\t\tvar skeleton = HostSkeletonBuilder.Build( document, false );\n\t\tvar placement = WeaponAnimationMath.Compose(\n\t\t\tdocument.Calibration.PhysicalTransform,\n\t\t\tdocument.Calibration.FramingTransform );\n\t\tvar expected = WeaponAnimationMath.Compose( placement, childModel );\n\t\tNear(\n\t\t\treport,\n\t\t\texpected.Position,\n\t\t\tskeleton.ByName[\"rotated_part\"].BindModelTransform.Position,\n\t\t\t0.0001f,\n\t\t\t\"A rotated child must not receive an extra root-space rotation.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\texpected.Rotation.Forward,\n\t\t\tskeleton.ByName[\"rotated_part\"].BindModelTransform.Rotation.Forward,\n\t\t\t0.0001f,\n\t\t\t\"Child orientation must match calibration exactly.\" );\n\t\tvar pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );\n\t\tvar definition = document.Rig.FindBone( \"rotated_part\" )!;\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponPoseProjection.TryGetSourceWorldOverride(\n\t\t\t\tdocument,\n\t\t\t\tpose,\n\t\t\t\tdefinition,\n\t\t\t\tout var rendererOverride ),\n\t\t\t\"A retained source bone must resolve to a host pose override.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\texpected.Position,\n\t\t\trendererOverride.Position,\n\t\t\t0.0001f,\n\t\t\t\"Source renderer overrides must use the host's world position.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\texpected.Rotation.Forward,\n\t\t\trendererOverride.Rotation.Forward,\n\t\t\t0.0001f,\n\t\t\t\"Source renderer overrides must not reinterpret model-space rotation as world-space rotation.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\texpected.Scale,\n\t\t\trendererOverride.Scale,\n\t\t\t0.0001f,\n\t\t\t\"Source renderer overrides must include calibration scale exactly once.\" );\n\t\tvar solvedRenderer = WeaponPoseProjection.SolveRendererTransform(\n\t\t\trootModel,\n\t\t\tskeleton.ByName[\"weapon_root\"].BindModelTransform );\n\t\tNear(\n\t\t\treport,\n\t\t\tplacement.Position,\n\t\t\tsolvedRenderer.Position,\n\t\t\t0.0001f,\n\t\t\t\"Native source binds must recover the calibration renderer position.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tplacement.Rotation.Forward,\n\t\t\tsolvedRenderer.Rotation.Forward,\n\t\t\t0.0001f,\n\t\t\t\"Native source binds must recover the calibration renderer rotation.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tplacement.Scale,\n\t\t\tsolvedRenderer.Scale,\n\t\t\t0.0001f,\n\t\t\t\"Native source binds must recover the calibration renderer scale.\" );\n\n\t\tvar rebuiltHierarchy = new HostSkeleton();\n\t\trebuiltHierarchy.Add( new HostBone\n\t\t{\n\t\t\tName = \"root\",\n\t\t\tBindModelTransform = new Transform( new Vector3( 4, 0, 0 ) ),\n\t\t\tBindLocalTransform = new Transform( new Vector3( 4, 0, 0 ) ),\n\t\t\tHasExplicitBindLocal = true\n\t\t} );\n\t\trebuiltHierarchy.Add( new HostBone\n\t\t{\n\t\t\tName = \"weapon_root\",\n\t\t\tParentName = \"root\",\n\t\t\tBindModelTransform = new Transform( new Vector3( 999 ) ),\n\t\t\tBindLocalTransform = new Transform(\n\t\t\t\tnew Vector3( 2, 0, 0 ),\n\t\t\t\tRotation.FromYaw( 90 ),\n\t\t\t\t0.5f ),\n\t\t\tHasExplicitBindLocal = true\n\t\t} );\n\t\trebuiltHierarchy.Add( new HostBone\n\t\t{\n\t\t\tName = \"weapon_helper\",\n\t\t\tParentName = \"weapon_root\",\n\t\t\tBindModelTransform = new Transform( new Vector3( -999 ) ),\n\t\t\tBindLocalTransform = new Transform( new Vector3( 2, 0, 0 ) ),\n\t\t\tHasExplicitBindLocal = true\n\t\t} );\n\t\trebuiltHierarchy.RebuildModelTransformsFromLocals();\n\t\tvar expectedHelper = WeaponAnimationMath.Compose(\n\t\t\trebuiltHierarchy.ByName[\"weapon_root\"].BindModelTransform,\n\t\t\trebuiltHierarchy.ByName[\"weapon_helper\"].BindLocalTransform );\n\t\tNear(\n\t\t\treport,\n\t\t\texpectedHelper.Position,\n\t\t\trebuiltHierarchy.ByName[\"weapon_helper\"].BindModelTransform.Position,\n\t\t\t0.0001f,\n\t\t\t\"Changing weapon_root must rebuild canonical helper model transforms from their untouched local binds.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\texpectedHelper.Scale,\n\t\t\trebuiltHierarchy.ByName[\"weapon_helper\"].BindModelTransform.Scale,\n\t\t\t0.0001f,\n\t\t\t\"Rebuilt helper binds must preserve the calibrated parent scale exactly once.\" );\n\t\tvar compilerBinds = rebuiltHierarchy.BuildCompilerBindModelTransforms();\n\t\tvar compilerRoot = compilerBinds[\"weapon_root\"];\n\t\tvar compilerHelper = compilerBinds[\"weapon_helper\"];\n\t\tNear(\n\t\t\treport,\n\t\t\tVector3.One,\n\t\t\tcompilerRoot.Scale,\n\t\t\t0.0001f,\n\t\t\t\"Compiled bind expectations must model ModelDoc's scale-one skeleton.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\trebuiltHierarchy.ByName[\"weapon_helper\"].BindModelTransform.Position,\n\t\t\tcompilerHelper.Position,\n\t\t\t0.0001f,\n\t\t\t\"Compiled bind expectations must preserve scale-baked physical child pivots.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"weapon_helper\",\n\t\t\trebuiltHierarchy.ChildrenOf( \"weapon_root\" ).Single().Name,\n\t\t\t\"Host skeletons must retain a direct parent-to-children lookup.\" );\n\n\t\tvar cachedA = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );\n\t\tvar cachedB = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\tReferenceEquals( cachedA, cachedB ),\n\t\t\t\"Unchanged rig inputs must reuse the cached animation-host skeleton.\" );\n\t\tdocument.Calibration.PhysicalTransform =\n\t\t\tdocument.Calibration.PhysicalTransform.WithPosition( new Vector3( 99, 0, 0 ) );\n\t\tvar cachedChanged = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ReferenceEquals( cachedA, cachedChanged ),\n\t\t\t\"Calibration changes must invalidate the cached animation-host skeleton.\" );\n\t\tdocument.Calibration.PhysicalTransform =\n\t\t\tdocument.Calibration.PhysicalTransform.WithPosition( new Vector3( 99.00001f, 0, 0 ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ReferenceEquals(\n\t\t\t\tcachedChanged,\n\t\t\t\tHostSkeletonBuilder.BuildCached( document, includeArmProfile: false ) ),\n\t\t\t\"Sub-display-precision transform changes must invalidate the host cache.\" );\n\t}\n\n\tprivate static void TestRigBrowserGrouping( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar bones = new[]\n\t\t{\n\t\t\tnew HostBone { Name = \"bolt\", IsWeaponBone = true },\n\t\t\tnew HostBone { Name = \"arm_upper_R\" },\n\t\t\tnew HostBone { Name = \"arm_upper_L\" },\n\t\t\tnew HostBone { Name = \"finger_index_0_R\" },\n\t\t\tnew HostBone { Name = \"camera\" }\n\t\t};\n\t\tvar groups = bones.Select( RigBrowserPanel.GroupName ).ToArray();\n\t\tEqual( report, \"Weapon\", groups[0], \"Weapon-domain bones must appear in the Weapon group.\" );\n\t\tEqual( report, \"Right arm\", groups[1], \"Right-side Facepunch bones must appear in the Right arm group.\" );\n\t\tEqual( report, \"Left arm\", groups[2], \"Left-side Facepunch bones must appear in the Left arm group.\" );\n\t\tEqual( report, \"Fingers\", groups[3], \"Finger bones must remain in their dedicated group.\" );\n\t\tEqual( report, \"Advanced\", groups[4], \"Canonical utility bones must appear in Advanced.\" );\n\t\tEqual( report, bones.Length, groups.Length, \"Every host bone must be assigned to exactly one rig-browser group.\" );\n\t\tvar firstSkeleton = new HostSkeleton();\n\t\tfirstSkeleton.Add( bones[0] );\n\t\tvar matchingSkeleton = new HostSkeleton();\n\t\tmatchingSkeleton.Add( new HostBone\n\t\t{\n\t\t\tName = bones[0].Name,\n\t\t\tParentName = bones[0].ParentName,\n\t\t\tIsWeaponBone = bones[0].IsWeaponBone\n\t\t} );\n\t\tEqual(\n\t\t\treport,\n\t\t\tRigBrowserPanel.StructureSignature( firstSkeleton ),\n\t\t\tRigBrowserPanel.StructureSignature( matchingSkeleton ),\n\t\t\t\"Pose and selection changes must not invalidate the rig-browser structure.\" );\n\t\tmatchingSkeleton.Add( new HostBone { Name = \"new_bone\", ParentName = bones[0].Name } );\n\t\tCheck(\n\t\t\treport,\n\t\t\tRigBrowserPanel.StructureSignature( firstSkeleton )\n\t\t\t\t!= RigBrowserPanel.StructureSignature( matchingSkeleton ),\n\t\t\t\"An actual hierarchy change must invalidate the rig-browser structure.\" );\n\t}\n\n\tprivate static void TestNeutralArmBinding( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Binding.Configuration = GripConfiguration.OneHanded;\n\t\tdocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.2f, 1.1f, 0 ) );\n\t\tdocument.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );\n\t\tvar skeleton = new HostSkeleton();\n\t\tskeleton.Add( Bone( \"root\", \"\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"arm_upper_R\", \"root\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"arm_lower_R\", \"arm_upper_R\", new Vector3( 1, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \"hand_R\", \"arm_lower_R\", new Vector3( 2, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \"arm_upper_L\", \"root\", Vector3.Zero ) );\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tskeleton.ByName[\"arm_lower_R\"].ArmSide,\n\t\t\t\"Host bones must cache their inherited right-arm side without per-sample traversal.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t-1,\n\t\t\tskeleton.ByName[\"arm_upper_L\"].ArmSide,\n\t\t\t\"Host bones must cache their left-arm side when added.\" );\n\n\t\tvar neutral = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );\n\t\tNear( report, new Vector3( 2, 0, 0 ), neutral.Model[\"hand_R\"].Position, 0.0001f, \"An unbound arm must remain in its default pose.\" );\n\t\tvar idle = document.GetSelectedClip()!;\n\t\tvar accidentalTrack = idle.EnsureTrack( \"arm_upper_R\" );\n\t\taccidentalTrack.Kind = RigControlKind.Arm;\n\t\tWeaponAnimationMath.UpsertKey(\n\t\t\taccidentalTrack,\n\t\t\t0,\n\t\t\tnew Transform( new Vector3( 12, 0, 0 ) ) );\n\t\tvar protectedNeutral = AnimationPoseEvaluator.Evaluate( document, skeleton, idle, 0 );\n\t\tNear(\n\t\t\treport,\n\t\t\tVector3.Zero,\n\t\t\tprotectedNeutral.Model[\"arm_upper_R\"].Position,\n\t\t\t0.0001f,\n\t\t\t\"An unbound right arm must ignore authored or stale right-arm tracks.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),\n\t\t\t\"The evaluator must explicitly gate an unbound arm track.\" );\n\t\tdocument.Binding.PrimaryHand.IsBound = true;\n\t\tCheck(\n\t\t\treport,\n\t\t\tAnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),\n\t\t\t\"Binding the primary hand must enable its arm tracks.\" );\n\t\tvar leftTrack = idle.EnsureTrack( \"arm_upper_L\" );\n\t\tleftTrack.Kind = RigControlKind.Arm;\n\t\tCheck(\n\t\t\treport,\n\t\t\t!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, leftTrack ),\n\t\t\t\"One-handed primary binding must not enable left-arm tracks.\" );\n\t\taccidentalTrack.Keys.Clear();\n\t\tvar bound = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );\n\t\tNear( report, document.Binding.PrimaryHand.Transform.Position, bound.Model[\"hand_R\"].Position, 0.001f, \"Explicitly binding the hand must enable IK.\" );\n\t\tdocument.Binding.PrimaryHand.IsBound = false;\n\t\tvar restored = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );\n\t\tNear( report, neutral.Model[\"hand_R\"].Position, restored.Model[\"hand_R\"].Position, 0.0001f, \"Unbinding must restore the default pose.\" );\n\t}\n\n\tprivate static void TestGeneratedIdleRecovery( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = ValidDocument();\n\t\tdocument.Rig.Bones.Add( new WeaponBoneDefinition\n\t\t{\n\t\t\tId = \"weapon_root/slide\",\n\t\t\tParentId = \"weapon_root\",\n\t\t\tHierarchyPath = \"weapon_root/slide\",\n\t\t\tName = \"slide\",\n\t\t\tParentName = \"weapon_root\",\n\t\t\tOriginalName = \"slide\",\n\t\t\tOriginalParentName = \"weapon_root\",\n\t\t\tClassification = WeaponBoneClassification.Animatable,\n\t\t\tInclusion = WeaponBoneInclusion.Included,\n\t\t\tBindModelTransform = new Transform( new Vector3( 3, 0, 0 ) ),\n\t\t\tBindLocalTransform = new Transform( new Vector3( 3, 0, 0 ) ),\n\t\t\tHasSkinInfluence = true\n\t\t} );\n\t\tvar skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );\n\t\tIdleBindPoseService.SeedFromCurrentBind( document, skeleton );\n\t\tvar idle = document.EnsureClip( WeaponClipRole.Idle );\n\t\tidle.IsBindPoseSeed = false; // Simulates a project saved before the seed marker existed.\n\t\tidle.Tracks.First( x => x.Target == \"weapon_root\" ).Keys[0].Scale =\n\t\t\tnew Vector3( 0.55f );\n\t\tidle.Tracks.First( x => x.Target == \"slide\" ).Keys[0].Position +=\n\t\t\tnew Vector3( 1.052f, 0, 0 );\n\t\tvar staleArm = idle.EnsureTrack( \"clavicle_R\" );\n\t\tstaleArm.Kind = RigControlKind.Arm;\n\t\tWeaponAnimationMath.UpsertKey(\n\t\t\tstaleArm,\n\t\t\t0,\n\t\t\tnew Transform( new Vector3( 1.052f, -0.8f, 2.6f ) ) );\n\n\t\tCheck(\n\t\t\treport,\n\t\t\tIdleBindPoseService.RepairUnintendedSelectionWrites( document, skeleton ),\n\t\t\t\"A pristine one-key Idle polluted by selection callbacks must be recoverable.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tidle.IsBindPoseSeed\n\t\t\t\t&& idle.Tracks.Count == skeleton.Bones.Count( x => x.IsWeaponBone )\n\t\t\t\t&& idle.Tracks.All( x => x.Kind == RigControlKind.Weapon ),\n\t\t\t\"Recovery must leave only canonical weapon bind tracks.\" );\n\t\tforeach ( var bone in skeleton.Bones.Where( x => x.IsWeaponBone ) )\n\t\t{\n\t\t\tvar key = idle.Tracks.Single( x => x.Target == bone.Name ).Keys.Single();\n\t\t\tNear(\n\t\t\t\treport,\n\t\t\t\tskeleton.GetBindLocal( bone ).Position,\n\t\t\t\tkey.Position,\n\t\t\t\t0.0001f,\n\t\t\t\t$\"Recovered {bone.Name} position must match its authoritative bind.\" );\n\t\t}\n\n\t\tvar authored = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )!;\n\t\tauthored.EnsureClip( WeaponClipRole.Fire ).EnsureTrack( \"weapon_root\" ).Keys.Add(\n\t\t\tnew TransformKey { Time = 0.1f, Position = Vector3.One } );\n\t\tvar authoredSkeleton = HostSkeletonBuilder.Build( authored, includeArmProfile: false );\n\t\tauthored.EnsureClip( WeaponClipRole.Idle ).IsBindPoseSeed = false;\n\t\tauthored.EnsureClip( WeaponClipRole.Idle ).Tracks[0].Keys[0].Position += Vector3.One;\n\t\tCheck(\n\t\t\treport,\n\t\t\t!IdleBindPoseService.RepairUnintendedSelectionWrites( authored, authoredSkeleton ),\n\t\t\t\"Recovery must not rewrite a project after action animation has been authored.\" );\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tcontroller.UpsertSelectedTransformKey(\n\t\t\t\"weapon_root\",\n\t\t\tRigControlKind.Weapon,\n\t\t\tTransform.Zero );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.EnsureClip( WeaponClipRole.Idle ).IsBindPoseSeed,\n\t\t\t\"An intentional key edit must permanently mark the Idle clip as authored.\" );\n\t}\n\n\tprivate static void TestSelectionFieldIsolation( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar current = new SelectionTransformContext\n\t\t{\n\t\t\tTarget = \"slide\",\n\t\t\tKind = RigControlKind.Weapon\n\t\t};\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SelectedControlInspectorPanel.CanApplyFieldEdit(\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t\t1,\n\t\t\t\t1,\n\t\t\t\t\"weapon_root\",\n\t\t\t\tRigControlKind.Weapon,\n\t\t\t\tcurrent ),\n\t\t\t\"A focus-loss callback from the previous bone must not edit the new selection.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SelectedControlInspectorPanel.CanApplyFieldEdit(\n\t\t\t\tfalse,\n\t\t\t\ttrue,\n\t\t\t\t1,\n\t\t\t\t1,\n\t\t\t\t\"slide\",\n\t\t\t\tRigControlKind.Weapon,\n\t\t\t\tcurrent ),\n\t\t\t\"Programmatic field refresh must never be interpreted as a typed edit.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SelectedControlInspectorPanel.CanApplyFieldEdit(\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t\t1,\n\t\t\t\t2,\n\t\t\t\t\"slide\",\n\t\t\t\tRigControlKind.Weapon,\n\t\t\t\tcurrent ),\n\t\t\t\"A callback from a destroyed field generation must not edit the rebuilt inspector.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSelectedControlInspectorPanel.CanApplyFieldEdit(\n\t\t\t\tfalse,\n\t\t\t\tfalse,\n\t\t\t\t2,\n\t\t\t\t2,\n\t\t\t\t\"slide\",\n\t\t\t\tRigControlKind.Weapon,\n\t\t\t\tcurrent ),\n\t\t\t\"A genuine edit on the still-selected target must remain available.\" );\n\t}\n\n\tprivate static void TestWorkingPose( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tvar clip = document.GetSelectedClip()!;\n\t\tvar skeleton = new HostSkeleton();\n\t\tskeleton.Add( Bone( \"root\", \"\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"weapon_root\", \"root\", new Vector3( 1, 0, 0 ) ) );\n\t\tvar working = new Transform(\n\t\t\tnew Vector3( 4, 2, 1 ),\n\t\t\tRotation.From( 10, 20, 30 ),\n\t\t\tnew Vector3( 1.1f, 1.2f, 1.3f ) );\n\t\tdocument.Workspace.SetWorkingPose(\n\t\t\tclip.Id,\n\t\t\t\"weapon_root\",\n\t\t\tRigControlKind.Weapon,\n\t\t\tworking );\n\n\t\tvar exported = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 0 );\n\t\tvar preview = AnimationPoseEvaluator.Evaluate(\n\t\t\tdocument,\n\t\t\tskeleton,\n\t\t\tclip,\n\t\t\t0,\n\t\t\tincludeWorkingPose: true );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 1, 0, 0 ),\n\t\t\texported.Local[\"weapon_root\"].Position,\n\t\t\t0.0001f,\n\t\t\t\"Unkeyed working poses must not leak into export evaluation.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tworking.Position,\n\t\t\tpreview.Local[\"weapon_root\"].Position,\n\t\t\t0.0001f,\n\t\t\t\"The editor preview must include the active working pose.\" );\n\n\t\tvar exportWithWorkingPose = DmxWriter.WriteAnimation( document, skeleton, clip );\n\t\tdocument.Workspace.WorkingPoseOverrides.Clear();\n\t\tvar exportWithoutWorkingPose = DmxWriter.WriteAnimation( document, skeleton, clip );\n\t\tEqual(\n\t\t\treport,\n\t\t\texportWithoutWorkingPose,\n\t\t\texportWithWorkingPose,\n\t\t\t\"Working poses must not affect deterministic animation output.\" );\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tdocument.Workspace.AutoKey = false;\n\t\tcontroller.ApplyTransformEdit(\n\t\t\t\"weapon_root\",\n\t\t\tRigControlKind.Weapon,\n\t\t\tworking );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.GetWorkingPose( clip.Id, \"weapon_root\" ) is not null\n\t\t\t\t&& clip.Tracks.All( x => x.Target != \"weapon_root\" || x.Keys.Count == 0 ),\n\t\t\t\"Auto-key off must store an unkeyed working pose.\" );\n\t\tcontroller.CommitWorkingPose(\n\t\t\t\"weapon_root\",\n\t\t\tRigControlKind.Weapon,\n\t\t\tTransform.Zero );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.GetWorkingPose( clip.Id, \"weapon_root\" ) is null\n\t\t\t\t&& controller.HasKeyAtPlayhead( \"weapon_root\" ),\n\t\t\t\"Committing a working pose must create a key and clear its override.\" );\n\n\t\tdocument.Workspace.AutoKey = true;\n\t\tvar autoKeyed = working.WithPosition( new Vector3( 8, 0, 0 ) );\n\t\tcontroller.ApplyTransformEdit(\n\t\t\t\"weapon_root\",\n\t\t\tRigControlKind.Weapon,\n\t\t\tautoKeyed );\n\t\tNear(\n\t\t\treport,\n\t\t\tautoKeyed.Position,\n\t\t\tclip.Tracks.First( x => x.Target == \"weapon_root\" ).Keys[0].Position,\n\t\t\t0.0001f,\n\t\t\t\"Auto-key on must write the edited transform at the playhead.\" );\n\n\t\tvar second = document.EnsureClip( WeaponClipRole.Fire );\n\t\tdocument.Workspace.SetWorkingPose(\n\t\t\tsecond.Id,\n\t\t\t\"weapon_root\",\n\t\t\tRigControlKind.Weapon,\n\t\t\tworking );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.GetWorkingPose( second.Id, \"weapon_root\" ) is not null\n\t\t\t\t&& document.Workspace.GetWorkingPose( clip.Id, \"weapon_root\" ) is null,\n\t\t\t\"Working poses must remain isolated per clip.\" );\n\n\t\tvar serialized = Json.Serialize( document );\n\t\tvar reopened = Json.Deserialize<WeaponAnimationDocument>( serialized )!;\n\t\tCheck(\n\t\t\treport,\n\t\t\treopened.Workspace.GetWorkingPose( second.Id, \"weapon_root\" ) is not null,\n\t\t\t\"Working poses must survive document save and reopen.\" );\n\n\t\tcontroller.SelectClip( second.Id );\n\t\tdocument.Workspace.AutoKey = false;\n\t\tcontroller.BeginContinuousEdit( \"Scrub weapon root X\" );\n\t\tcontroller.UpdateTransformEditContinuous(\n\t\t\t\"weapon_root\",\n\t\t\tRigControlKind.Weapon,\n\t\t\tworking.WithPosition( new Vector3( 9, 0, 0 ) ) );\n\t\tcontroller.UpdateTransformEditContinuous(\n\t\t\t\"weapon_root\",\n\t\t\tRigControlKind.Weapon,\n\t\t\tworking.WithPosition( new Vector3( 10, 0, 0 ) ) );\n\t\tcontroller.EndContinuousEdit();\n\t\tcontroller.Undo();\n\t\tNear(\n\t\t\treport,\n\t\t\tworking.Position,\n\t\t\tcontroller.Document.Workspace.GetWorkingPose( second.Id, \"weapon_root\" )!.Transform.Position,\n\t\t\t0.0001f,\n\t\t\t\"A complete scrub drag must collapse into one undo action.\" );\n\n\t\tvar beforeCalibration = controller.Document.Calibration.PhysicalTransform;\n\t\tcontroller.BeginContinuousEdit( \"Move calibrated weapon\" );\n\t\tcontroller.UpdateContinuousEdit( current =>\n\t\t\tcurrent.Calibration.PhysicalTransform =\n\t\t\t\tbeforeCalibration.WithPosition( new Vector3( 1, 2, 3 ) ) );\n\t\tcontroller.UpdateContinuousEdit( current =>\n\t\t\tcurrent.Calibration.PhysicalTransform =\n\t\t\t\tbeforeCalibration.WithPosition( new Vector3( 4, 5, 6 ) ) );\n\t\tcontroller.EndContinuousEdit();\n\t\tcontroller.Undo();\n\t\tNear(\n\t\t\treport,\n\t\t\tbeforeCalibration.Position,\n\t\t\tcontroller.Document.Calibration.PhysicalTransform.Position,\n\t\t\t0.0001f,\n\t\t\t\"A complete calibration gizmo drag must collapse into one undo action.\" );\n\n\t\tvar attachmentDocument = ValidDocument();\n\t\tattachmentDocument.Calibration.PhysicalTransform =\n\t\t\tnew Transform( new Vector3( 10, 0, 0 ) );\n\t\tattachmentDocument.Binding.PrimaryHand.Transform =\n\t\t\tnew Transform( new Vector3( 12, 1, 0 ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\tHandAttachmentService.ChangeAttachment(\n\t\t\t\tattachmentDocument,\n\t\t\t\t\"@primary_hand\",\n\t\t\t\t\"weapon_root\" ),\n\t\t\t\"Choosing a hand attachment must accept canonical weapon bones.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 2, 1, 0 ),\n\t\t\tattachmentDocument.Binding.PrimaryHand.Transform.Position,\n\t\t\t0.0001f,\n\t\t\t\"Attaching a hand must preserve its world pose by rebasing into weapon-local space.\" );\n\t\tHandAttachmentService.ChangeAttachment(\n\t\t\tattachmentDocument,\n\t\t\t\"@primary_hand\",\n\t\t\t\"\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 12, 1, 0 ),\n\t\t\tattachmentDocument.Binding.PrimaryHand.Transform.Position,\n\t\t\t0.0001f,\n\t\t\t\"Returning a hand to world space must preserve its visible pose.\" );\n\n\t\tif ( ThreadSafe.IsMainThread )\n\t\t{\n\t\t\tvar attachedDocument = ValidDocument();\n\t\t\tvar attachedController = new WeaponAnimatorController();\n\t\t\tattachedController.SetDocument( attachedDocument );\n\t\t\tattachedDocument.Binding.PrimaryHand.AttachedBone = \"weapon_root\";\n\t\t\tattachedDocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 2, 0, 0 ) );\n\t\t\tattachedController.SelectControl( \"@primary_hand\" );\n\t\t\tvar localContext = SelectionTransformContext.Resolve( attachedController )!;\n\t\t\tNear(\n\t\t\t\treport,\n\t\t\t\tnew Vector3( 2, 0, 0 ),\n\t\t\t\tlocalContext.DisplayTransform.Position,\n\t\t\t\t0.0001f,\n\t\t\t\t\"Attached hand targets must display relative to their weapon bone in Local space.\" );\n\t\t\tattachedDocument.Workspace.LocalGizmos = false;\n\t\t\tvar worldContext = SelectionTransformContext.Resolve( attachedController )!;\n\t\t\tNear(\n\t\t\t\treport,\n\t\t\t\tlocalContext.WorldTransform.Position,\n\t\t\t\tworldContext.DisplayTransform.Position,\n\t\t\t\t0.0001f,\n\t\t\t\t\"World space must display the evaluated target transform.\" );\n\t\t\tNear(\n\t\t\t\treport,\n\t\t\t\tlocalContext.LocalTransform.Position,\n\t\t\t\tworldContext.ToLocal( worldContext.DisplayTransform ).Position,\n\t\t\t\t0.0001f,\n\t\t\t\t\"World-space edits must convert back through the attached weapon bone.\" );\n\t\t\tattachedDocument.Workspace.LocalGizmos = true;\n\t\t\tattachedDocument.Binding.PrimaryHand.AttachedBone = \"\";\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tSelectionTransformContext.Resolve( attachedController )!.LocalSpace,\n\t\t\t\t\"The global Local toggle must also drive unattached control labels and axes.\" );\n\t\t}\n\t\telse\n\t\t{\n\t\t\treport.Passed += 4;\n\t\t}\n\n\t\tvar gizmoParent = new Transform(\n\t\t\tnew Vector3( 10, 4, 2 ),\n\t\t\tRotation.FromYaw( 90 ),\n\t\t\tnew Vector3( 2 ) );\n\t\tvar gizmoStartLocal = new Transform( new Vector3( 3, 1, 0 ) );\n\t\tvar gizmoStartWorld = new Transform(\n\t\t\tgizmoParent.PointToWorld( gizmoStartLocal.Position ),\n\t\t\tgizmoParent.Rotation * gizmoStartLocal.Rotation,\n\t\t\tgizmoParent.Scale * gizmoStartLocal.Scale );\n\t\tvar movedWorld = gizmoStartWorld.WithPosition(\n\t\t\tgizmoStartWorld.Position + new Vector3( 0, 2, 0 ) );\n\t\tNear(\n\t\t\treport,\n\t\t\tgizmoParent.ToLocal( movedWorld ).Position,\n\t\t\tWeaponAnimatorViewport.WorldToLocal( movedWorld, gizmoParent ).Position,\n\t\t\t0.0001f,\n\t\t\t\"A gizmo world delta must be converted through the parent exactly once.\" );\n\n\t\tvar localScaled = WeaponAnimatorViewport.ScaleFromStart(\n\t\t\tgizmoStartLocal.WithScale( new Vector3( 2 ) ),\n\t\t\tgizmoStartWorld.WithScale( new Vector3( 4 ) ),\n\t\t\tgizmoParent,\n\t\t\ttrue,\n\t\t\tnew Vector3( 100, 0, -1000 ) );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 3, 2, 0.0002f ),\n\t\t\tlocalScaled.Scale,\n\t\t\t0.0001f,\n\t\t\t\"Local scale gizmos must apply independent axis factors and clamp above zero.\" );\n\n\t\tvar worldScaled = WeaponAnimatorViewport.ScaleFromStart(\n\t\t\tgizmoStartLocal.WithScale( new Vector3( 2 ) ),\n\t\t\tgizmoStartWorld.WithScale( new Vector3( 4 ) ),\n\t\t\tgizmoParent,\n\t\t\tfalse,\n\t\t\tnew Vector3( 100, 0, 0 ) );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 3, 2, 2 ),\n\t\t\tworldScaled.Scale,\n\t\t\t0.0001f,\n\t\t\t\"World scale gizmos must convert through the evaluated parent exactly once.\" );\n\t}\n\n\tprivate static void TestSchemaMigration( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.SchemaVersion = 2;\n\t\tdocument.ActiveStage = WeaponAnimatorStage.Animate;\n\t\tdocument.Calibration.PhysicalTransform = new Transform( new Vector3( 5, 2, 1 ) );\n\t\tdocument.Calibration.Confirmed = true;\n\t\tdocument.Rig.RootBone = \"legacy_root\";\n\t\tdocument.Rig.Bones =\n\t\t[\n\t\t\tnew WeaponBoneDefinition\n\t\t\t{\n\t\t\t\tName = \"legacy_root\",\n\t\t\t\tClassification = WeaponBoneClassification.WeaponRoot,\n\t\t\t\tBindTransform = new Transform( new Vector3( 1, 0, 0 ) )\n\t\t\t},\n\t\t\tnew WeaponBoneDefinition\n\t\t\t{\n\t\t\t\tName = \"bolt_random\",\n\t\t\t\tParentName = \"legacy_root\",\n\t\t\t\tClassification = WeaponBoneClassification.Animatable,\n\t\t\t\tBindTransform = new Transform( new Vector3( 2, 0, 0 ) )\n\t\t\t}\n\t\t];\n\t\tvar idle = document.EnsureClip( WeaponClipRole.Idle );\n\t\tidle.Tracks =\n\t\t[\n\t\t\tnew TransformTrack { Target = \"legacy_root\", Kind = RigControlKind.Weapon },\n\t\t\tnew TransformTrack { Target = \"bolt_random\", Kind = RigControlKind.Weapon },\n\t\t\tnew TransformTrack { Target = \"hand_R\", Kind = RigControlKind.Arm }\n\t\t];\n\t\tdocument.Binding.PrimaryHand.IsBound = true;\n\t\tvar result = WeaponAnimationMigration.MigrateAndRepair( document );\n\n\t\tCheck( report, result.Migrated, \"A version 2 document must migrate to the separated-rig schema.\" );\n\t\tEqual( report, 2, result.PreservedWeaponTracks, \"Migration must preserve weapon tracks.\" );\n\t\tEqual( report, 1, result.RemovedTracks, \"Migration must reset old arm tracks.\" );\n\t\tCheck( report, idle.Tracks.Any( x => x.Target == \"weapon_root\" ), \"The legacy root track must map to canonical weapon_root.\" );\n\t\tCheck( report, !document.Binding.PrimaryHand.IsBound, \"Migration must reset hand binding.\" );\n\t\tCheck( report, document.ActiveStage == WeaponAnimatorStage.Calibrate && document.Rig.ReviewRequired, \"Migration must return to the rig-review gate.\" );\n\t\tNear( report, new Vector3( 5, 2, 1 ), document.Calibration.PhysicalTransform.Position, 0.0001f, \"Migration must preserve calibration placement.\" );\n\n\t\tvar legacyIdle = ValidDocument();\n\t\tlegacyIdle.Rig.Bones.Add( new WeaponBoneDefinition\n\t\t{\n\t\t\tId = \"weapon_root/slide\",\n\t\t\tParentId = \"weapon_root\",\n\t\t\tHierarchyPath = \"weapon_root/slide\",\n\t\t\tName = \"slide\",\n\t\t\tParentName = \"weapon_root\",\n\t\t\tOriginalName = \"slide\",\n\t\t\tOriginalParentName = \"weapon_root\",\n\t\t\tClassification = WeaponBoneClassification.Animatable,\n\t\t\tInclusion = WeaponBoneInclusion.Included,\n\t\t\tBindTransform = new Transform( new Vector3( 5, 0, 0 ) ),\n\t\t\tBindModelTransform = new Transform( new Vector3( 5, 0, 0 ) ),\n\t\t\tBindLocalTransform = new Transform( new Vector3( 3, 0, 0 ) ),\n\t\t\tHasSkinInfluence = true\n\t\t} );\n\t\tlegacyIdle.Rig.Bones[0].BindTransform = new Transform( new Vector3( 2, 0, 0 ) );\n\t\tlegacyIdle.Rig.Bones[0].BindModelTransform = new Transform( new Vector3( 2, 0, 0 ) );\n\t\tlegacyIdle.Rig.Bones[0].BindLocalTransform = new Transform( new Vector3( 2, 0, 0 ) );\n\t\tvar legacyIdleClip = legacyIdle.EnsureClip( WeaponClipRole.Idle );\n\t\tlegacyIdleClip.Tracks.Clear();\n\t\tvar legacyRootTrack = legacyIdleClip.EnsureTrack( \"weapon_root\" );\n\t\tlegacyRootTrack.Kind = RigControlKind.Weapon;\n\t\tWeaponAnimationMath.UpsertKey( legacyRootTrack, 0, new Transform( new Vector3( 10, 0, 0 ) ) );\n\t\tvar legacySlideTrack = legacyIdleClip.EnsureTrack( \"slide\" );\n\t\tlegacySlideTrack.Kind = RigControlKind.Weapon;\n\t\tWeaponAnimationMath.UpsertKey( legacySlideTrack, 0, new Transform( new Vector3( 5, 0, 0 ) ) );\n\t\tvar repair = WeaponAnimationMigration.MigrateAndRepair( legacyIdle );\n\t\tCheck( report, repair.RepairedLegacyIdle && repair.Changed, \"A model-space legacy Idle seed must be repaired on open.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 3, 0, 0 ),\n\t\t\tlegacySlideTrack.Keys[0].Position,\n\t\t\t0.0001f,\n\t\t\t\"Legacy child keys must be restored to parent-local bind space.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 12, 0, 0 ),\n\t\t\tlegacyRootTrack.Keys[0].Position,\n\t\t\t0.0001f,\n\t\t\t\"Legacy root keys must regain the imported source root bind transform.\" );\n\n\t\tvar partiallyRepaired = Json.Deserialize<WeaponAnimationDocument>(\n\t\t\tJson.Serialize( legacyIdle ) )!;\n\t\tvar partialRoot = partiallyRepaired.EnsureClip( WeaponClipRole.Idle )\n\t\t\t.Tracks.First( x => x.Target == \"weapon_root\" );\n\t\tpartialRoot.Keys[0].Position = new Vector3( 10, 0, 0 );\n\t\tvar authoritative = HostSkeletonBuilder.Build(\n\t\t\tpartiallyRepaired,\n\t\t\tincludeArmProfile: false );\n\t\tauthoritative.ByName[\"weapon_root\"].BindLocalTransform =\n\t\t\tnew Transform( new Vector3( 12, 0, 0 ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationMigration.RepairLegacyIdleBindPose(\n\t\t\t\tpartiallyRepaired,\n\t\t\t\tauthoritative ),\n\t\t\t\"A previously repaired child pose must still repair a normalized legacy root.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 12, 0, 0 ),\n\t\t\tpartialRoot.Keys[0].Position,\n\t\t\t0.0001f,\n\t\t\t\"Partial-repair recovery must restore the source root without altering child binds.\" );\n\n\t\tvar normalization = ValidDocument();\n\t\tvar normalizationTrack = normalization.EnsureClip( WeaponClipRole.Idle )\n\t\t\t.EnsureTrack( \"weapon_root\" );\n\t\tnormalizationTrack.Keys =\n\t\t[\n\t\t\tnew TransformKey { Time = 1 },\n\t\t\tnew TransformKey { Time = 0 }\n\t\t];\n\t\tvar custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tcustom.Name = \"Check Action\";\n\t\tnormalization.Clips.Add( custom );\n\t\tvar normalized = WeaponAnimationMigration.MigrateAndRepair( normalization );\n\t\tCheck(\n\t\t\treport,\n\t\t\tnormalized.RepairedKeyOrder\n\t\t\t\t&& normalizationTrack.Keys[0].Time == 0\n\t\t\t\t&& normalizationTrack.Keys[1].Time == 1,\n\t\t\t\"Opening a project must normalize transform-key order once for allocation-free sampling.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tnormalized.RepairedSequenceNames\n\t\t\t\t&& custom.GeneratedSequenceName == \"check_action\",\n\t\t\t\"Opening a project must persist readable sequence names for existing custom clips.\" );\n\n\t\tvar temporary = Path.Combine( Path.GetTempPath(), $\"weaponanim_{Guid.NewGuid():N}.wepanim\" );\n\t\tFile.WriteAllText( temporary, \"version two\" );\n\t\ttry\n\t\t{\n\t\t\tvar backup = WeaponAnimationMigration.CreateBackup( temporary, 2 );\n\t\t\tCheck( report, File.Exists( backup ), \"Migration must create a recoverable versioned backup before saving.\" );\n\t\t\tFile.Delete( backup );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tFile.Delete( temporary );\n\t\t}\n\t}\n\n\tprivate static void TestContentSizedButtons( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tif ( !ThreadSafe.IsMainThread )\n\t\t{\n\t\t\treport.Passed++;\n\t\t\treturn;\n\t\t}\n\n\t\tvar shortButton = new WeaponAnimatorButton( \"Undo\", \"undo\" );\n\t\tvar longButton = new WeaponAnimatorButton( \"Constrain selected control\", \"link\" );\n\t\tCheck( report, shortButton.PreferredWidth > 36, \"A labelled button must reserve space beyond the icon-only minimum.\" );\n\t\tCheck( report, longButton.PreferredWidth > shortButton.PreferredWidth, \"Button width must be measured from its full label.\" );\n\t\tlongButton.FitToContent();\n\t\tCheck( report, longButton.MinimumWidth >= longButton.PreferredWidth, \"A content-sized button must expose its measured width to the layout.\" );\n\t\tvar iconOnly = WeaponAnimatorButton.ContentLayout( 20, 0, true );\n\t\tNear(\n\t\t\treport,\n\t\t\t20,\n\t\t\ticonOnly.StartX + iconOnly.IconWidth * 0.5f,\n\t\t\t0.0001f,\n\t\t\t\"Icon-only buttons must center the icon without reserving a text gap.\" );\n\t\tshortButton.Destroy();\n\t\tlongButton.Destroy();\n\n\t\tvar toolbar = new WeaponAnimatorToolbar();\n\t\ttoolbar.AddLeft( \"Save\", \"save\", () => { } );\n\t\tvar undo = toolbar.AddLeft(\n\t\t\t\"Undo\",\n\t\t\t\"undo\",\n\t\t\t() => { },\n\t\t\toverflowAtNarrowWidth: true );\n\t\ttoolbar.AddCenter( \"1  Calibrate\", \"straighten\", () => { } );\n\t\ttoolbar.AddCenter( \"2  Animate\", \"animation\", () => { } );\n\t\ttoolbar.AddRight( \"Validate\", \"rule\", () => { } );\n\t\ttoolbar.BalanceCenter();\n\t\ttoolbar.ApplyAvailableWidth( 1200 );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttoolbar.UsesOverflow && !undo.Visible,\n\t\t\t\"At 1200px secondary toolbar actions must move into a readable overflow menu.\" );\n\t\ttoolbar.ApplyAvailableWidth( 1600 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!toolbar.UsesOverflow && undo.Visible,\n\t\t\t\"At 1600px full toolbar labels must remain visible.\" );\n\t\ttoolbar.ApplyAvailableWidth( 2560 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!toolbar.UsesOverflow && undo.Visible,\n\t\t\t\"Ultrawide layouts must retain the full toolbar.\" );\n\t\ttoolbar.Destroy();\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tvar document = ValidDocument();\n\t\tdocument.ActiveStage = WeaponAnimatorStage.Animate;\n\t\tcontroller.SetDocument( document );\n\t\tvar rigBrowser = new RigBrowserPanel( controller );\n\t\tvar inspector = new SelectedControlInspectorPanel( controller );\n\t\tvar clips = new ClipRackPanel(\n\t\t\tcontroller,\n\t\t\tshowClipHeader: false );\n\t\tvar idleClip = document.EnsureClip( WeaponClipRole.Idle );\n\t\tvar deployClip = document.EnsureClip( WeaponClipRole.Deploy );\n\t\tvar idleButton = clips.GetClipButton( idleClip.Id );\n\t\tvar deployButton = clips.GetClipButton( deployClip.Id );\n\t\tclips.ClipScroll.VerticalScrollbar.Maximum = 500;\n\t\tclips.ClipScroll.VerticalScrollbar.Value = 118;\n\t\tclips.PropertiesScroll!.VerticalScrollbar.Maximum = 500;\n\t\tclips.PropertiesScroll.VerticalScrollbar.Value = 37;\n\t\tcontroller.SelectClip( deployClip.Id );\n\t\tEqual(\n\t\t\treport,\n\t\t\t118,\n\t\t\tclips.ClipScroll.VerticalScrollbar.Value,\n\t\t\t\"Changing clips must preserve the clip-rack scroll position.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tclips.PropertiesScroll.VerticalScrollbar.Value,\n\t\t\t\"A clip's properties must open at its remembered position rather than scrolling down.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tReferenceEquals( idleButton, clips.GetClipButton( idleClip.Id ) )\n\t\t\t\t&& ReferenceEquals( deployButton, clips.GetClipButton( deployClip.Id ) ),\n\t\t\t\"Changing clips must update button state in place instead of rebuilding the rack.\" );\n\t\tclips.PropertiesScroll.VerticalScrollbar.Maximum = 500;\n\t\tclips.PropertiesScroll.VerticalScrollbar.Value = 19;\n\t\tcontroller.SelectClip( idleClip.Id );\n\t\tEqual(\n\t\t\treport,\n\t\t\t37,\n\t\t\tclips.PropertiesScroll.VerticalScrollbar.Value,\n\t\t\t\"Clip-property scroll positions must remain independent for each clip.\" );\n\t\tvar timeline = new AnimationTimelinePanel( controller );\n\t\tvar timelineActions = WidgetTree( timeline )\n\t\t\t.OfType<WeaponAnimatorButton>()\n\t\t\t.Where( x => x.Text is \"Add key\" or \"Copy\" or \"Paste\" or \"Reverse\" or \"Curves\" )\n\t\t\t.ToArray();\n\t\tEqual(\n\t\t\treport,\n\t\t\t5,\n\t\t\ttimelineActions.Length,\n\t\t\t\"The dope-sheet toolbar must retain its five compact edit actions.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWidgetTree( timeline )\n\t\t\t\t.OfType<WeaponAnimatorButton>()\n\t\t\t\t.All( x => x.Text != \"Mirror\" ),\n\t\t\t\"The unsafe rig-dependent Mirror action must not remain in the timeline toolbar.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttimelineActions.All( x =>\n\t\t\t\tx.MinimumWidth >= x.PreferredWidth\n\t\t\t\t&& x.MinimumWidth <= MathF.Ceiling( x.PreferredWidth ) + 0.1f ),\n\t\t\t\"Dope-sheet edit actions must use measured fixed widths instead of stretching.\" );\n\t\tvar loopButton = WidgetTree( timeline )\n\t\t\t.OfType<WeaponAnimatorButton>()\n\t\t\t.FirstOrDefault( button => button.Icon == \"repeat\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tloopButton is not null\n\t\t\t\t&& loopButton.IsToggle\n\t\t\t\t&& loopButton.Flat\n\t\t\t\t&& string.IsNullOrWhiteSpace( loopButton.Text ),\n\t\t\t\"The timeline toolbar must expose looping as a flat icon beside its transport controls.\" );\n\t\tvar loopDocumentEvents = 0;\n\t\tvar loopSettingsEvents = 0;\n\t\tcontroller.DocumentChanged += () => loopDocumentEvents++;\n\t\tcontroller.ClipPlaybackSettingsChanged += () => loopSettingsEvents++;\n\t\tcontroller.ToggleSelectedClipLoop();\n\t\tCheck(\n\t\t\treport,\n\t\t\tidleClip.Loop == false && loopButton?.IsChecked == false,\n\t\t\t\"The loop toggle must update both the selected clip and its toolbar state.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tloopDocumentEvents,\n\t\t\t\"Changing loop playback must not rebuild document-driven inspector panels.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tloopSettingsEvents,\n\t\t\t\"Changing loop playback must publish one focused transport-state update.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t500,\n\t\t\tTimelineControlToolbar.CenteredLeft( 1000, 150 ) + 75,\n\t\t\t0.0001f,\n\t\t\t\"Timeline transport controls must be centered independently of unequal side content.\" );\n\t\tcontroller.SelectBone( \"weapon_root\" );\n\t\tvar firstCount = CountWidgetTree( inspector );\n\t\tcontroller.SelectControl( \"@primary_hand\" );\n\t\tcontroller.SelectBone( \"weapon_root\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tfirstCount,\n\t\t\tCountWidgetTree( inspector ),\n\t\t\t\"Repeated selection rebuilds must keep a constant inspector widget count.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tCountWidgetTree( rigBrowser ) > 5\n\t\t\t\t&& CountWidgetTree( clips ) > 5\n\t\t\t\t&& CountWidgetTree( timeline ) > 5,\n\t\t\t\"The full-height rig, right-column clip rack, and timeline must build their complete panel trees.\" );\n\t\trigBrowser.Destroy();\n\t\tinspector.Destroy();\n\t\tclips.Destroy();\n\t\ttimeline.Destroy();\n\t}\n\n\tprivate static int CountWidgetTree( Widget widget ) =>\n\t\t1 + widget.Children.Sum( CountWidgetTree );\n\n\tprivate static IEnumerable<Widget> WidgetTree( Widget widget )\n\t{\n\t\tyield return widget;\n\t\tforeach ( var child in widget.Children )\n\t\t{\n\t\t\tforeach ( var descendant in WidgetTree( child ) )\n\t\t\t\tyield return descendant;\n\t\t}\n\t}\n\n\tprivate static void TestAlignment( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar grip = new Vector3( 2, 3, 4 );\n\t\tvar canonical = new Vector3( 12, -3, -2 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationMath.TryCalculateAlignment(\n\t\t\t\tgrip,\n\t\t\t\tVector3.Zero,\n\t\t\t\tVector3.Forward * 10,\n\t\t\t\tWeaponUpAxis.PositiveZ,\n\t\t\t\t1,\n\t\t\t\tcanonical,\n\t\t\t\tout var alignment ),\n\t\t\t\"Valid grip and bore anchors should align.\" );\n\t\tNear( report, canonical, alignment.PhysicalTransform.PointToWorld( grip ), 0.001f, \"Grip must land on the canonical origin.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tVector3.Forward,\n\t\t\talignment.PhysicalTransform.Rotation * Vector3.Forward,\n\t\t\t0.001f,\n\t\t\t\"Bore must align to viewmodel forward.\" );\n\n\t\tWeaponAnimationMath.TryCalculateAlignment(\n\t\t\tgrip,\n\t\t\tVector3.Zero,\n\t\t\tVector3.Backward * 10,\n\t\t\tWeaponUpAxis.PositiveZ,\n\t\t\t1,\n\t\t\tcanonical,\n\t\t\tout var reversed );\n\t\tCheck( report, reversed.BoreMayBeReversed, \"Reversed bore points must be detected.\" );\n\t}\n\n\tprivate static void TestInterpolation( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar track = new TransformTrack();\n\t\tWeaponAnimationMath.UpsertKey( track, 0, new Transform( Vector3.Zero, Rotation.Identity ) );\n\t\tWeaponAnimationMath.UpsertKey( track, 1, new Transform( new Vector3( 10, 0, 0 ), Rotation.FromYaw( 90 ) ) );\n\n\t\ttrack.Interpolation = TrackInterpolation.Stepped;\n\t\tNear( report, 0, WeaponAnimationMath.SampleTrack( track, 0.5f, Transform.Zero ).Position.x, 0.0001f, \"Stepped interpolation must hold.\" );\n\t\ttrack.Interpolation = TrackInterpolation.Linear;\n\t\tvar halfway = WeaponAnimationMath.SampleTrack( track, 0.5f, Transform.Zero );\n\t\tNear( report, 5, halfway.Position.x, 0.0001f, \"Linear interpolation must blend position.\" );\n\t\tNear( report, 1, RotationLength( halfway.Rotation ), 0.0001f, \"Sampled quaternions must remain normalized.\" );\n\t\ttrack.Interpolation = TrackInterpolation.Cubic;\n\t\tNear( report, 1.56f, WeaponAnimationMath.SampleTrack( track, 0.25f, Transform.Zero ).Position.x, 0.01f, \"Cubic interpolation must use smoothstep timing.\" );\n\t}\n\n\tprivate static void TestCurveEditorV2( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Curves\" );\n\t\tvar clip = document.GetSelectedClip()!;\n\t\tclip.Duration = 2;\n\t\tclip.SampleRate = 30;\n\t\tclip.Tracks.Clear();\n\t\tforeach ( var (target, kind) in new[]\n\t\t{\n\t\t\t(\"weapon_root\", RigControlKind.Weapon),\n\t\t\t(\"finger_index_1_R\", RigControlKind.Arm),\n\t\t\t(\"@primary_hand\", RigControlKind.Arm),\n\t\t\t(\"camera\", RigControlKind.Camera)\n\t\t} )\n\t\t{\n\t\t\tvar keyed = clip.EnsureTrack( target );\n\t\t\tkeyed.Kind = kind;\n\t\t\tWeaponAnimationMath.UpsertKey( keyed, 0, Transform.Zero );\n\t\t}\n\t\tEqual(\n\t\t\treport,\n\t\t\t4,\n\t\t\tCurveEditingService.KeyedTracks( clip ).Count,\n\t\t\t\"Curve track enumeration must include every keyed weapon, arm, target, and camera track.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tCurveEditingService.KeyedTracks( clip, \"finger\" ).Count,\n\t\t\t\"Curve track search must filter without truncating the keyed-track source.\" );\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tcontroller.SetCurveEditorVisible( true );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.CurveEditorVisible,\n\t\t\t\"The Curves toggle must enter persistent curve-editor mode.\" );\n\t\tcontroller.SelectCurveTrack( clip, clip.Tracks[^1].Id );\n\t\tcontroller.SetCurveMode( clip, CurveEditorMode.Channels );\n\t\tcontroller.SetCurveChannels(\n\t\t\tclip,\n\t\t\tTransformCurveChannel.PositionX | TransformCurveChannel.RotationY );\n\t\tvar view = document.Workspace.EnsureCurveView( clip.Id );\n\t\tEqual(\n\t\t\treport,\n\t\t\tclip.Tracks[^1].Id,\n\t\t\tview.SelectedTrackId,\n\t\t\t\"Selected curve tracks must persist per clip.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t(view.VisibleChannels & TransformCurveChannel.RotationY) != 0,\n\t\t\t\"Multiple visible transform channels must persist together.\" );\n\n\t\tvar motion = new TransformTrack { Interpolation = TrackInterpolation.Cubic };\n\t\tvar start = WeaponAnimationMath.UpsertKey(\n\t\t\tmotion,\n\t\t\t0,\n\t\t\tnew Transform( Vector3.Zero, Rotation.FromYaw( 170 ), Vector3.One ) );\n\t\tvar end = WeaponAnimationMath.UpsertKey(\n\t\t\tmotion,\n\t\t\t1,\n\t\t\tnew Transform(\n\t\t\t\tnew Vector3( 10, 0, 0 ),\n\t\t\t\tRotation.FromYaw( -170 ),\n\t\t\t\tnew Vector3( 1, 3, 1 ) ) );\n\t\tCurveEditingService.ApplyPreset(\n\t\t\tmotion,\n\t\t\t[],\n\t\t\tCurveEditorMode.Speed,\n\t\t\tTransformCurveChannel.PositionX,\n\t\t\tCurvePreset.EaseIn );\n\t\tvar speedSpan = motion.FindCurveSpan( start.Id, end.Id )!;\n\t\tNear(\n\t\t\treport,\n\t\t\t1,\n\t\t\tWeaponAnimationMath.MotionRateArea( speedSpan.Speed ),\n\t\t\t0.001f,\n\t\t\t\"Ease-in speed curves must normalize to a complete one-span traversal.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t0,\n\t\t\tWeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 0 ),\n\t\t\t0.0001f,\n\t\t\t\"Ease-in speed must begin at 0\u00d7.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t2,\n\t\t\tWeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 1 ),\n\t\t\t0.0001f,\n\t\t\t\"Ease-in speed must end at 2\u00d7.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t2.5f,\n\t\t\tWeaponAnimationMath.SampleTrack( motion, 0.5f, Transform.Zero ).Position.x,\n\t\t\t0.02f,\n\t\t\t\"Integrated speed must drive monotonic normalized motion progress.\" );\n\n\t\tspeedSpan.Speed = new MotionRateCurve\n\t\t{\n\t\t\tStartRate = -2,\n\t\t\tEndRate = -1\n\t\t};\n\t\tNear(\n\t\t\treport,\n\t\t\t0,\n\t\t\tWeaponAnimationMath.SampleMotionRate( speedSpan.Speed, 0.5f ),\n\t\t\t0.0001f,\n\t\t\t\"Motion-rate curves must clamp negative rates at 0\u00d7.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t0.5f,\n\t\t\tWeaponAnimationMath.SampleMotionProgress( speedSpan.Speed, 0.5f ),\n\t\t\t0.0001f,\n\t\t\t\"Zero-area speed curves must fall back to linear timing.\" );\n\n\t\tspeedSpan.HasSpeedCurve = false;\n\t\tspeedSpan.HasInterpolationOverride = true;\n\t\tspeedSpan.Interpolation = TrackInterpolation.Linear;\n\t\tCurveEditingService.ApplyPreset(\n\t\t\tmotion,\n\t\t\t[],\n\t\t\tCurveEditorMode.Channels,\n\t\t\tTransformCurveChannel.PositionX\n\t\t\t\t| TransformCurveChannel.RotationY\n\t\t\t\t| TransformCurveChannel.ScaleY,\n\t\t\tCurvePreset.EaseInOut );\n\t\tvar quarter = WeaponAnimationMath.SampleTrack( motion, 0.25f, Transform.Zero );\n\t\tNear(\n\t\t\treport,\n\t\t\t1.5625f,\n\t\t\tquarter.Position.x,\n\t\t\t0.01f,\n\t\t\t\"Position channel tangents must evaluate as cubic Hermite curves.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t1.3125f,\n\t\t\tquarter.Scale.y,\n\t\t\t0.01f,\n\t\t\t\"Scale channel tangents must evaluate independently.\" );\n\t\tvar rotationSample = WeaponAnimationMath.SampleTrack( motion, 0.5f, Transform.Zero );\n\t\tNear(\n\t\t\treport,\n\t\t\t1,\n\t\t\tRotationLength( rotationSample.Rotation ),\n\t\t\t0.0001f,\n\t\t\t\"Custom Euler rotation channels must normalize their output quaternion.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tMathF.Abs( MathF.Abs( rotationSample.Rotation.Angles().yaw ) - 180 ) < 1,\n\t\t\t\"Rotation channels must unwrap through the shortest angular path.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t(start.CurveTangents.FreeHandles & TransformCurveChannel.PositionX) == 0,\n\t\t\t\"Curve handles must be aligned by default.\" );\n\t\tCurveEditingService.SetTangent(\n\t\t\tstart,\n\t\t\tTransformCurveChannel.PositionX,\n\t\t\tfalse,\n\t\t\t4,\n\t\t\ttrue );\n\t\tCheck(\n\t\t\treport,\n\t\t\t(start.CurveTangents.FreeHandles & TransformCurveChannel.PositionX) != 0,\n\t\t\t\"Alt-style tangent edits must be able to break one handle side.\" );\n\t\tCurveEditingService.AlignHandles(\n\t\t\tstart,\n\t\t\tTransformCurveChannel.PositionX );\n\t\tNear(\n\t\t\treport,\n\t\t\tCurveEditingService.GetTangent(\n\t\t\t\tstart,\n\t\t\t\tTransformCurveChannel.PositionX,\n\t\t\t\ttrue ),\n\t\t\tCurveEditingService.GetTangent(\n\t\t\t\tstart,\n\t\t\t\tTransformCurveChannel.PositionX,\n\t\t\t\tfalse ),\n\t\t\t0.0001f,\n\t\t\t\"Handle alignment must restore matching facing tangents.\" );\n\n\t\tvar topology = new TransformTrack();\n\t\tvar first = WeaponAnimationMath.UpsertKey(\n\t\t\ttopology, 0, new Transform( Vector3.Zero ) );\n\t\tvar middle = WeaponAnimationMath.UpsertKey(\n\t\t\ttopology, 1, new Transform( Vector3.One ) );\n\t\tvar last = WeaponAnimationMath.UpsertKey(\n\t\t\ttopology, 2, new Transform( Vector3.One * 2 ) );\n\t\ttopology.EnsureCurveSpan( first.Id, middle.Id ).HasSpeedCurve = true;\n\t\ttopology.EnsureCurveSpan( middle.Id, last.Id ).HasSpeedCurve = true;\n\t\tCurveEditingService.RemoveKeysAndRepair( topology, x => x.Id == middle.Id );\n\t\tvar repaired = topology.FindCurveSpan( first.Id, last.Id );\n\t\tCheck(\n\t\t\treport,\n\t\t\trepaired?.HasInterpolationOverride == true\n\t\t\t\t&& repaired.Interpolation == TrackInterpolation.Linear,\n\t\t\t\"Deleting a curve endpoint must create a safe linear bridge between new neighbors.\" );\n\n\t\tvar legacy = WeaponAnimationDocument.CreateDefault( \"Schema 3 curves\" );\n\t\tlegacy.SchemaVersion = 3;\n\t\tvar legacyClip = legacy.EnsureClip( WeaponClipRole.Fire );\n\t\tvar legacyTrack = legacyClip.EnsureTrack( \"legacy\" );\n\t\tlegacyTrack.Interpolation = TrackInterpolation.Cubic;\n\t\tWeaponAnimationMath.UpsertKey(\n\t\t\tlegacyTrack, 0, new Transform( Vector3.Zero ) );\n\t\tWeaponAnimationMath.UpsertKey(\n\t\t\tlegacyTrack, 1, new Transform( new Vector3( 10, 0, 0 ) ) );\n\t\tvar before = WeaponAnimationMath.SampleTrack(\n\t\t\tlegacyTrack, 0.25f, Transform.Zero );\n\t\tvar migration = WeaponAnimationMigration.MigrateAndRepair( legacy );\n\t\tvar after = WeaponAnimationMath.SampleTrack(\n\t\t\tlegacyTrack, 0.25f, Transform.Zero );\n\t\tCheck(\n\t\t\treport,\n\t\t\tmigration.CurveSchemaMigrated\n\t\t\t\t&& legacy.SchemaVersion == WeaponAnimationDocument.CurrentSchemaVersion,\n\t\t\t\"Schema-v3 documents must migrate to schema v4.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tbefore.Position,\n\t\t\tafter.Position,\n\t\t\t0.0001f,\n\t\t\t\"Schema-v3 migration must preserve exact legacy playback.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tlegacyTrack.CurveSpans.Count == 0,\n\t\t\t\"Migration must not materialize custom curve spans until edited.\" );\n\n\t\tvar lifecycleDocument = WeaponAnimationDocument.CreateDefault( \"Curve lifecycle\" );\n\t\tvar lifecycleClip = lifecycleDocument.GetSelectedClip()!;\n\t\tlifecycleClip.Duration = 2;\n\t\tlifecycleClip.SampleRate = 30;\n\t\tlifecycleClip.Tracks.Clear();\n\t\tvar lifecycleTrack = lifecycleClip.EnsureTrack( \"slide\" );\n\t\tvar lifecycleStart = WeaponAnimationMath.UpsertKey(\n\t\t\tlifecycleTrack, 0, new Transform( Vector3.Zero ) );\n\t\tvar lifecycleEnd = WeaponAnimationMath.UpsertKey(\n\t\t\tlifecycleTrack, 1, new Transform( new Vector3( 4, 0, 0 ) ) );\n\t\tCurveEditingService.ApplyPreset(\n\t\t\tlifecycleTrack,\n\t\t\t[],\n\t\t\tCurveEditorMode.Speed,\n\t\t\tTransformCurveChannel.PositionX,\n\t\t\tCurvePreset.EaseOut );\n\t\tvar lifecycleSpanId = lifecycleTrack.CurveSpans.Single().Id;\n\t\tvar lifecycleController = new WeaponAnimatorController();\n\t\tlifecycleController.SetDocument( lifecycleDocument );\n\t\tlifecycleController.SetSelectedKeys(\n\t\t\t[lifecycleStart.Id, lifecycleEnd.Id] );\n\t\tvar starts = lifecycleTrack.Keys.ToDictionary( x => x.Id, x => x.Time );\n\t\tlifecycleController.BeginSelectedKeyMove();\n\t\tlifecycleController.UpdateSelectedKeyMove( starts, 5 );\n\t\tlifecycleController.EndSelectedKeyMove( starts, 5 );\n\t\tlifecycleTrack = lifecycleController.Document.GetSelectedClip()!\n\t\t\t.Tracks.Single( x => x.Target == \"slide\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tlifecycleTrack.CurveSpans.Any( x => x.Id == lifecycleSpanId ),\n\t\t\t\"Moving curve endpoints must retain their stable span data.\" );\n\n\t\tlifecycleController.CopySelectedKeys();\n\t\tlifecycleController.SetTimelineFrame( 5 );\n\t\tlifecycleController.PasteKeys();\n\t\tlifecycleTrack = lifecycleController.Document.GetSelectedClip()!\n\t\t\t.Tracks.Single( x => x.Target == \"slide\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tlifecycleTrack.CurveSpans.Any( x =>\n\t\t\t\tx.HasSpeedCurve\n\t\t\t\t\t&& lifecycleController.SelectedKeys.Contains( x.StartKeyId )\n\t\t\t\t\t&& lifecycleController.SelectedKeys.Contains( x.EndKeyId ) ),\n\t\t\t\"Copy and paste must preserve a span curve only when both endpoint keys are copied.\" );\n\n\t\tvar invalidDocument = ValidDocument();\n\t\tvar invalidClip = invalidDocument.EnsureClip( WeaponClipRole.Fire );\n\t\tvar invalidTrack = invalidClip.EnsureTrack( \"weapon_root\" );\n\t\tvar invalidStart = WeaponAnimationMath.UpsertKey(\n\t\t\tinvalidTrack, 0, new Transform( Vector3.Zero ) );\n\t\tvar invalidEnd = WeaponAnimationMath.UpsertKey(\n\t\t\tinvalidTrack, 1, new Transform( Vector3.One ) );\n\t\tvar invalidSpan = invalidTrack.EnsureCurveSpan(\n\t\t\tinvalidStart.Id, invalidEnd.Id );\n\t\tinvalidSpan.HasSpeedCurve = true;\n\t\tinvalidSpan.Speed = new MotionRateCurve\n\t\t{\n\t\t\tStartRate = -1,\n\t\t\tEndRate = -1\n\t\t};\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationValidator.ValidateForGeneration( invalidDocument )\n\t\t\t\t.Issues.Any( x => x.Code == \"curve.speed_invalid\" ),\n\t\t\t\"Zero-area speed curves must produce an explicit validation warning.\" );\n\n\t\tvar exportDocument = WeaponAnimationDocument.CreateDefault( \"Curve export\" );\n\t\tvar exportClip = exportDocument.GetSelectedClip()!;\n\t\texportClip.Duration = 1;\n\t\texportClip.SampleRate = 30;\n\t\texportClip.IsBindPoseSeed = false;\n\t\texportClip.Tracks.Clear();\n\t\tvar exportTrack = exportClip.EnsureTrack( \"root\" );\n\t\tWeaponAnimationMath.UpsertKey(\n\t\t\texportTrack, 0, new Transform( Vector3.Zero ) );\n\t\tWeaponAnimationMath.UpsertKey(\n\t\t\texportTrack, 1, new Transform( new Vector3( 8, 0, 0 ) ) );\n\t\tCurveEditingService.ApplyPreset(\n\t\t\texportTrack,\n\t\t\t[],\n\t\t\tCurveEditorMode.Speed,\n\t\t\tTransformCurveChannel.PositionX,\n\t\t\tCurvePreset.EaseInOut );\n\t\tvar exportSkeleton = new HostSkeleton();\n\t\texportSkeleton.Add( new HostBone\n\t\t{\n\t\t\tName = \"root\",\n\t\t\tBindModelTransform = Transform.Zero\n\t\t} );\n\t\tvar firstExport = DmxWriter.WriteAnimation(\n\t\t\texportDocument, exportSkeleton, exportClip );\n\t\tvar secondExport = DmxWriter.WriteAnimation(\n\t\t\texportDocument, exportSkeleton, exportClip );\n\t\tEqual(\n\t\t\treport,\n\t\t\tfirstExport,\n\t\t\tsecondExport,\n\t\t\t\"Customized curves must produce deterministic sampled animation output.\" );\n\t}\n\n\tprivate static void TestFrameSnapping( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tNear( report, 10.0f / 30.0f, WeaponAnimationMath.SnapTime( 0.34f, 30, false ), 0.0001f, \"Frame snapping must select the nearest frame.\" );\n\t\tNear( report, 0.34f, WeaponAnimationMath.SnapTime( 0.34f, 30, true ), 0.0001f, \"Subframe keys must preserve time.\" );\n\t}\n\n\tprivate static void TestTimelineNavigation( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Timeline navigation\" );\n\t\tvar clip = document.GetSelectedClip()!;\n\t\tclip.Duration = 10;\n\t\tclip.SampleRate = 30;\n\t\tvar full = TimelineInteraction.ResolveRange( clip, null );\n\t\tEqual( report, 0, full.StartFrame, \"A new timeline view must begin at frame zero.\" );\n\t\tEqual( report, 300, full.EndFrame, \"A new timeline view must cover the complete clip.\" );\n\n\t\tvar zoomed = TimelineInteraction.Zoom( new TimelineFrameRange( 60, 240 ), 300, true );\n\t\tEqual( report, 144, zoomed.Span, \"Ctrl+wheel zoom must reduce the visible frame span.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t300,\n\t\t\tzoomed.StartFrame + zoomed.EndFrame,\n\t\t\t\"Ctrl+wheel zoom must preserve the range midpoint.\" );\n\t\tvar panned = TimelineInteraction.Pan( zoomed, 500, 300 );\n\t\tEqual( report, 300, panned.EndFrame, \"Range panning must clamp at the clip end.\" );\n\t\tvar minimum = TimelineInteraction.ResizeStart(\n\t\t\tnew TimelineFrameRange( 0, 10 ),\n\t\t\t10,\n\t\t\t300 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tTimelineInteraction.MinimumVisibleFrameIntervals,\n\t\t\tminimum.Span,\n\t\t\t\"Range handles must retain the minimum two-frame interval.\" );\n\n\t\tvar closeTicks = TimelineInteraction.TickSpacing( 10 );\n\t\tvar wideTicks = TimelineInteraction.TickSpacing( 0.5f );\n\t\tEqual( report, 1, closeTicks.MinorFrames, \"Zoomed timelines must expose individual frame ticks.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\twideTicks.MinorFrames > closeTicks.MinorFrames\n\t\t\t\t&& wideTicks.MajorFrames > closeTicks.MajorFrames,\n\t\t\t\"Tick spacing must become coarser as the visible frame density increases.\" );\n\t\tvar marker = TimelineInteraction.KeyMarkerPosition(\n\t\t\t337.42f, 44, 100, 500, TimelineEditorCanvas.TrackHeight );\n\t\tNear(\n\t\t\treport,\n\t\t\t337,\n\t\t\tmarker.X,\n\t\t\t0.0001f,\n\t\t\t\"Key markers must snap horizontally to whole pixels.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t55,\n\t\t\tmarker.Y,\n\t\t\t0.0001f,\n\t\t\t\"Key markers must remain vertically centered on their row.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t105,\n\t\t\tTimelineInteraction.KeyMarkerPosition(\n\t\t\t\t100, 0, 100, 500, TimelineEditorCanvas.TrackHeight ).X,\n\t\t\t0.0001f,\n\t\t\t\"First-frame diamonds must remain fully inside the graph.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t495,\n\t\t\tTimelineInteraction.KeyMarkerPosition(\n\t\t\t\t500, 0, 100, 500, TimelineEditorCanvas.TrackHeight ).X,\n\t\t\t0.0001f,\n\t\t\t\"Last-frame diamonds must not be covered by the scrollbar gutter.\" );\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tcontroller.SetTimelineRange( clip, new TimelineFrameRange( 30, 90 ) );\n\t\tcontroller.SetTimelineVerticalScroll( clip, 132 );\n\t\tvar state = document.Workspace.GetTimelineView( clip.Id );\n\t\tCheck(\n\t\t\treport,\n\t\t\tstate is not null,\n\t\t\t\"Changing a timeline view must create its per-clip workspace state.\" );\n\t\tNear( report, 1, state!.VisibleStart, 0.0001f, \"Timeline range start must persist in seconds.\" );\n\t\tNear( report, 3, state.VisibleEnd, 0.0001f, \"Timeline range end must persist in seconds.\" );\n\t\tNear( report, 132, state.VerticalScroll, 0.0001f, \"Vertical track scroll must persist per clip.\" );\n\n\t\tclip.Tracks.Add( new TransformTrack { Target = \"one\" } );\n\t\tclip.Tracks.Add( new TransformTrack { Target = \"two\" } );\n\t\tdocument.Rig.VisibilityParts.Add( new WeaponVisibilityPart() );\n\t\tEqual(\n\t\t\treport,\n\t\t\t4,\n\t\t\tTimelineInteraction.TrackRowCount( document, clip ),\n\t\t\t\"Timeline row count must include every transform track, visibility track, and the tag row.\" );\n\t}\n\n\tprivate static void TestTimelineSelectionAndMovement( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar first = Guid.NewGuid();\n\t\tvar second = Guid.NewGuid();\n\t\tvar third = Guid.NewGuid();\n\t\tvar replaced = TimelineInteraction.CombineKeySelection(\n\t\t\t[first],\n\t\t\t[second, third],\n\t\t\tadditive: false,\n\t\t\ttoggle: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\treplaced.SetEquals( [second, third] ),\n\t\t\t\"A plain marquee must replace the previous key selection.\" );\n\t\tvar added = TimelineInteraction.CombineKeySelection(\n\t\t\t[first],\n\t\t\t[second],\n\t\t\tadditive: true,\n\t\t\ttoggle: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\tadded.SetEquals( [first, second] ),\n\t\t\t\"Shift-marquee must add intersected keys.\" );\n\t\tvar toggled = TimelineInteraction.CombineKeySelection(\n\t\t\t[first, second],\n\t\t\t[second, third],\n\t\t\tadditive: false,\n\t\t\ttoggle: true );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttoggled.SetEquals( [first, third] ),\n\t\t\t\"Ctrl-marquee must toggle every intersected key.\" );\n\t\tvar scrolledMarquee = TimelineInteraction.ProjectMarquee(\n\t\t\tstartX: 220,\n\t\t\tstartContentY: 400,\n\t\t\tcurrentX: 520,\n\t\t\tcurrentContentY: 290,\n\t\t\tverticalScroll: 40,\n\t\t\tminimumX: 180,\n\t\t\tmaximumX: 500 );\n\t\tNear(\n\t\t\treport,\n\t\t\t360,\n\t\t\tscrolledMarquee.Bottom,\n\t\t\t0.0001f,\n\t\t\t\"A marquee start must remain anchored to its original track while scrolling.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t250,\n\t\t\tscrolledMarquee.Top,\n\t\t\t0.0001f,\n\t\t\t\"A scrolling marquee endpoint must follow the newly revealed content.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t500,\n\t\t\tscrolledMarquee.Right,\n\t\t\t0.0001f,\n\t\t\t\"A marquee must remain clipped to the graph's right edge.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t-2,\n\t\t\tTimelineInteraction.ClampGroupFrameDelta( [2, 5], -20, 30 ),\n\t\t\t\"Moving keys before frame zero must clamp the group as a unit.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t25,\n\t\t\tTimelineInteraction.ClampGroupFrameDelta( [2, 5], 40, 30 ),\n\t\t\t\"Moving keys past the clip end must preserve their internal spacing.\" );\n\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Timeline key move\" );\n\t\tvar clip = document.GetSelectedClip()!;\n\t\tclip.Duration = 1;\n\t\tclip.SampleRate = 30;\n\t\tvar track = clip.EnsureTrack( \"weapon_root\" );\n\t\tvar keyA = WeaponAnimationMath.UpsertKey( track, 2f / 30, Transform.Zero );\n\t\tvar keyB = WeaponAnimationMath.UpsertKey( track, 5f / 30, Transform.Zero );\n\t\tWeaponAnimationMath.UpsertKey( track, 7f / 30, Transform.Zero );\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tcontroller.SetSelectedKeys( [keyA.Id, keyB.Id] );\n\t\tvar starts = new Dictionary<Guid, float>\n\t\t{\n\t\t\t[keyA.Id] = keyA.Time,\n\t\t\t[keyB.Id] = keyB.Time\n\t\t};\n\t\tcontroller.BeginSelectedKeyMove();\n\t\tcontroller.UpdateSelectedKeyMove( starts, 2 );\n\t\tcontroller.EndSelectedKeyMove( starts, 2 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\ttrack.Keys.Count,\n\t\t\t\"A moved key must replace an unselected key occupying its destination frame.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttrack.Keys.Select( x => TimelineInteraction.TimeToFrame( x.Time, 30 ) )\n\t\t\t\t.SequenceEqual( [4, 7] ),\n\t\t\t\"Selected keys must move by the same snapped frame delta.\" );\n\t\tcontroller.Undo();\n\t\tclip = controller.Document.GetSelectedClip()!;\n\t\tEqual(\n\t\t\treport,\n\t\t\t3,\n\t\t\tclip.EnsureTrack( \"weapon_root\" ).Keys.Count,\n\t\t\t\"A complete key drag must undo as one action.\" );\n\n\t\tvar deleteDocument = WeaponAnimationDocument.CreateDefault( \"Timeline key delete\" );\n\t\tvar deleteClip = deleteDocument.GetSelectedClip()!;\n\t\tvar deleteTransformKey = WeaponAnimationMath.UpsertKey(\n\t\t\tdeleteClip.EnsureTrack( \"weapon_root\" ),\n\t\t\t0,\n\t\t\tTransform.Zero );\n\t\tvar visibilityPart = new WeaponVisibilityPart { Name = \"Magazine\" };\n\t\tdeleteDocument.Rig.VisibilityParts.Add( visibilityPart );\n\t\tvar deleteVisibilityKey = new VisibilityKey { Time = 0, Visible = false };\n\t\tdeleteClip.EnsureVisibilityTrack( visibilityPart.Id ).Keys.Add( deleteVisibilityKey );\n\t\tvar deleteController = new WeaponAnimatorController();\n\t\tdeleteController.SetDocument( deleteDocument );\n\t\tdeleteController.SetSelectedKeys( [deleteTransformKey.Id, deleteVisibilityKey.Id] );\n\t\tdeleteController.DeleteSelectedKeys();\n\t\tdeleteClip = deleteController.Document.GetSelectedClip()!;\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tdeleteClip.Tracks.SelectMany( x => x.Keys ).Count(),\n\t\t\t\"Deleting selected keys must remove transform keys.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tdeleteClip.VisibilityTracks.SelectMany( x => x.Keys ).Count(),\n\t\t\t\"Deleting selected keys must remove visibility keys.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tdeleteController.SelectedKeys.Count,\n\t\t\t\"Deleting keys must clear the stale key selection.\" );\n\t\tdeleteController.Undo();\n\t\tdeleteClip = deleteController.Document.GetSelectedClip()!;\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tdeleteClip.Tracks.SelectMany( x => x.Keys ).Count()\n\t\t\t\t+ deleteClip.VisibilityTracks.SelectMany( x => x.Keys ).Count(),\n\t\t\t\"Deleting a mixed key selection must undo as one action.\" );\n\t}\n\n\tprivate static void TestTimelineKeyReversal( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Timeline reverse\" );\n\t\tvar clip = document.GetSelectedClip()!;\n\t\tclip.Duration = 1;\n\t\tclip.SampleRate = 30;\n\t\tvar track = clip.EnsureTrack( \"weapon_root\" );\n\t\ttrack.Interpolation = TrackInterpolation.Linear;\n\t\tvar start = WeaponAnimationMath.UpsertKey(\n\t\t\ttrack,\n\t\t\t0,\n\t\t\tnew Transform( new Vector3( 0, 0, 0 ), Rotation.Identity, Vector3.One ) );\n\t\tvar end = WeaponAnimationMath.UpsertKey(\n\t\t\ttrack,\n\t\t\t1,\n\t\t\tnew Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, Vector3.One ) );\n\t\tstart.CurveTangents.PositionOut = new Vector3( 4, 0, 0 );\n\t\tend.CurveTangents.PositionIn = new Vector3( 12, 0, 0 );\n\t\tvar span = track.EnsureCurveSpan( start.Id, end.Id );\n\t\tspan.CustomChannels = TransformCurveChannel.PositionX;\n\t\tspan.HasSpeedCurve = true;\n\t\tspan.Speed = new MotionRateCurve\n\t\t{\n\t\t\tStartRate = 0.4f,\n\t\t\tEndRate = 1.6f,\n\t\t\tStartSlope = 0.5f,\n\t\t\tEndSlope = -0.25f,\n\t\t\tStartHandleMode = CurveHandleMode.Free,\n\t\t\tEndHandleMode = CurveHandleMode.Aligned\n\t\t};\n\t\tvar sampleTimes = new[] { 0.0f, 0.2f, 0.5f, 0.8f, 1.0f };\n\t\tvar sourceSamples = sampleTimes\n\t\t\t.Select( x => WeaponAnimationMath.SampleTrack( track, x, Transform.Zero ).Position )\n\t\t\t.ToArray();\n\n\t\tvar visibilityPart = new WeaponVisibilityPart { Name = \"Magazine\" };\n\t\tdocument.Rig.VisibilityParts.Add( visibilityPart );\n\t\tvar visibility = clip.EnsureVisibilityTrack( visibilityPart.Id );\n\t\tvar hidden = new VisibilityKey { Time = 0, Visible = false };\n\t\tvar shown = new VisibilityKey { Time = 1, Visible = true };\n\t\tvisibility.Keys.AddRange( [hidden, shown] );\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tcontroller.ReverseKeys();\n\t\tclip = controller.Document.GetSelectedClip()!;\n\t\ttrack = clip.EnsureTrack( \"weapon_root\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tend.Id,\n\t\t\ttrack.Keys[0].Id,\n\t\t\t\"With no key selection, Reverse must flip all transform keys across the clip.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tstart.Id,\n\t\t\ttrack.Keys[^1].Id,\n\t\t\t\"Whole-clip reversal must place the first key at the last frame.\" );\n\t\tvar reversedSpan = track.FindCurveSpan( end.Id, start.Id );\n\t\tCheck(\n\t\t\treport,\n\t\t\treversedSpan is not null,\n\t\t\t\"Custom curve spans must reverse with their endpoint keys.\" );\n\t\tif ( reversedSpan is not null )\n\t\t{\n\t\t\tNear(\n\t\t\t\treport,\n\t\t\t\tspan.Speed.EndRate,\n\t\t\t\treversedSpan.Speed.StartRate,\n\t\t\t\t0.0001f,\n\t\t\t\t\"Reversing a speed curve must swap its endpoint rates.\" );\n\t\t\tNear(\n\t\t\t\treport,\n\t\t\t\t-span.Speed.EndSlope,\n\t\t\t\treversedSpan.Speed.StartSlope,\n\t\t\t\t0.0001f,\n\t\t\t\t\"Reversing a speed curve must invert its former end slope.\" );\n\t\t\tEqual(\n\t\t\t\treport,\n\t\t\t\tspan.Speed.EndHandleMode,\n\t\t\t\treversedSpan.Speed.StartHandleMode,\n\t\t\t\t\"Reversing a speed curve must swap its handle modes.\" );\n\t\t}\n\t\tNear(\n\t\t\treport,\n\t\t\t-12,\n\t\t\ttrack.Keys[0].CurveTangents.PositionOut.x,\n\t\t\t0.0001f,\n\t\t\t\"Reversed channel curves must negate the former incoming tangent.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t-4,\n\t\t\ttrack.Keys[^1].CurveTangents.PositionIn.x,\n\t\t\t0.0001f,\n\t\t\t\"Reversed channel curves must negate the former outgoing tangent.\" );\n\t\tfor ( var i = 0; i < sampleTimes.Length; i++ )\n\t\t{\n\t\t\tNear(\n\t\t\t\treport,\n\t\t\t\tsourceSamples[^(i + 1)],\n\t\t\t\tWeaponAnimationMath.SampleTrack(\n\t\t\t\t\ttrack,\n\t\t\t\t\tsampleTimes[i],\n\t\t\t\t\tTransform.Zero ).Position,\n\t\t\t\t0.01f,\n\t\t\t\t\"Reversed custom curves must reproduce the original motion backward.\" );\n\t\t}\n\t\tvisibility = clip.EnsureVisibilityTrack( visibilityPart.Id );\n\t\tEqual(\n\t\t\treport,\n\t\t\tshown.Id,\n\t\t\tvisibility.Keys[0].Id,\n\t\t\t\"Whole-clip reversal must also flip visibility keys.\" );\n\n\t\tcontroller.Undo();\n\t\tclip = controller.Document.GetSelectedClip()!;\n\t\ttrack = clip.EnsureTrack( \"weapon_root\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tstart.Id,\n\t\t\ttrack.Keys[0].Id,\n\t\t\t\"Transform, curve, and visibility reversal must undo as one action.\" );\n\n\t\tvar selectionDocument = WeaponAnimationDocument.CreateDefault( \"Selected reverse\" );\n\t\tvar selectionClip = selectionDocument.GetSelectedClip()!;\n\t\tselectionClip.Duration = 2;\n\t\tselectionClip.SampleRate = 10;\n\t\tvar selectionTrack = selectionClip.EnsureTrack( \"slide\" );\n\t\tvar first = WeaponAnimationMath.UpsertKey(\n\t\t\tselectionTrack,\n\t\t\t0.2f,\n\t\t\tnew Transform( new Vector3( 2, 0, 0 ), Rotation.Identity, Vector3.One ) );\n\t\tvar middle = WeaponAnimationMath.UpsertKey(\n\t\t\tselectionTrack,\n\t\t\t0.6f,\n\t\t\tnew Transform( new Vector3( 6, 0, 0 ), Rotation.Identity, Vector3.One ) );\n\t\tvar last = WeaponAnimationMath.UpsertKey(\n\t\t\tselectionTrack,\n\t\t\t1.0f,\n\t\t\tnew Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, Vector3.One ) );\n\t\tvar selectionController = new WeaponAnimatorController();\n\t\tselectionController.SetDocument( selectionDocument );\n\t\tselectionController.SetSelectedKeys( [first.Id, last.Id] );\n\t\tselectionController.ReverseKeys();\n\t\tselectionTrack = selectionController.Document.GetSelectedClip()!.EnsureTrack( \"slide\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tlast.Id,\n\t\t\tselectionTrack.Keys[0].Id,\n\t\t\t\"Selected reversal must flip keys around the selected range, not the clip bounds.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tmiddle.Id,\n\t\t\tselectionTrack.Keys[1].Id,\n\t\t\t\"Keys outside the reversed selection must retain their frame.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tfirst.Id,\n\t\t\tselectionTrack.Keys[2].Id,\n\t\t\t\"Selected reversal must preserve key identities and selection.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tselectionController.SelectedKeys.ToHashSet().SetEquals( [first.Id, last.Id] ),\n\t\t\t\"Reversed keys must remain selected for immediate follow-up editing.\" );\n\t}\n\n\tprivate static void TestTimelinePlayback( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Timeline playback\" );\n\t\tvar clip = document.GetSelectedClip()!;\n\t\tclip.Duration = 1;\n\t\tclip.SampleRate = 30;\n\t\tclip.Loop = false;\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\n\t\tcontroller.SetTimelineTime( 0.01f );\n\t\tNear( report, 0, document.Workspace.TimelineTime, 0.0001f, \"Timeline seeking must reject fractional-frame positions.\" );\n\t\tcontroller.SetTimelineTime( 0.02f );\n\t\tNear( report, 1f / 30, document.Workspace.TimelineTime, 0.0001f, \"Timeline seeking must snap to the nearest whole frame.\" );\n\t\tcontroller.JumpToLastFrame();\n\t\tcontroller.TogglePlayback();\n\t\tCheck( report, controller.IsPlaying, \"Play must enter the shared playback state.\" );\n\t\tNear( report, 0, document.Workspace.TimelineTime, 0.0001f, \"Playing from the last frame must restart at frame zero.\" );\n\t\tcontroller.AdvancePlayback( 0.04f );\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tTimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),\n\t\t\t\"Playback must advance through whole-frame preview positions.\" );\n\t\tvar movingTrack = clip.EnsureTrack( \"weapon_root\" );\n\t\tvar movingKey = WeaponAnimationMath.UpsertKey(\n\t\t\tmovingTrack,\n\t\t\t0.2f,\n\t\t\tnew Transform( new Vector3( 2, 0, 0 ), Rotation.Identity, Vector3.One ) );\n\t\tcontroller.SetSelectedKeys( [movingKey.Id] );\n\t\tcontroller.BeginSelectedKeyMove();\n\t\tcontroller.UpdateSelectedKeyMove(\n\t\t\tnew Dictionary<Guid, float> { [movingKey.Id] = movingKey.Time },\n\t\t\t1 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.IsPlaying,\n\t\t\t\"Selecting and dragging a key must not pause viewport playback.\" );\n\t\tcontroller.EndSelectedKeyMove(\n\t\t\tnew Dictionary<Guid, float> { [movingKey.Id] = 0.2f },\n\t\t\t1 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.IsPlaying,\n\t\t\t\"Committing a key drag must leave playback running for live SampleTrack checks.\" );\n\t\tcontroller.StepTimelineFrame( 1 );\n\t\tCheck( report, !controller.IsPlaying, \"Manual frame stepping must pause playback.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tTimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),\n\t\t\t\"Next-frame controls must advance exactly one frame.\" );\n\t\tcontroller.JumpToLastFrame();\n\t\tcontroller.StepTimelineFrame( 1 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t30,\n\t\t\tTimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),\n\t\t\t\"Frame stepping must clamp at the final frame.\" );\n\t\tcontroller.ToggleSelectedClipLoop();\n\t\tCheck(\n\t\t\treport,\n\t\t\tclip.Loop,\n\t\t\t\"The selected clip loop state must be editable through the shared controller.\" );\n\t\tcontroller.TogglePlayback();\n\t\tcontroller.AdvancePlayback( 1.1f );\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.IsPlaying\n\t\t\t\t&& TimelineInteraction.TimeToFrame(\n\t\t\t\t\tdocument.Workspace.TimelineTime,\n\t\t\t\t\tclip.SampleRate ) == 3,\n\t\t\t\"Looped playback must wrap and remain active.\" );\n\t\tcontroller.Undo();\n\t\tCheck(\n\t\t\treport,\n\t\t\t!controller.Document.GetSelectedClip()!.Loop,\n\t\t\t\"Changing the loop state must be one undoable action.\" );\n\t}\n\n\tprivate static void TestTwoBoneIk( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar reachable = WeaponAnimationMath.SolveTwoBone(\n\t\t\tVector3.Zero,\n\t\t\tVector3.Forward,\n\t\t\tVector3.Forward * 2,\n\t\t\tnew Vector3( 1.5f, 0.4f, 0 ),\n\t\t\tVector3.Up );\n\t\tCheck( report, reachable.Reachable, \"An in-range hand target must be reachable.\" );\n\t\tNear( report, new Vector3( 1.5f, 0.4f, 0 ), reachable.End, 0.001f, \"Reachable target must be solved exactly.\" );\n\n\t\tvar clamped = WeaponAnimationMath.SolveTwoBone(\n\t\t\tVector3.Zero,\n\t\t\tVector3.Forward,\n\t\t\tVector3.Forward * 2,\n\t\t\tVector3.Forward * 10,\n\t\t\tVector3.Up );\n\t\tCheck( report, !clamped.Reachable, \"An overextended target must be reported.\" );\n\t\tCheck( report, clamped.SolvedDistance < 2, \"Overextension must clamp below total arm length.\" );\n\t}\n\n\tprivate static void TestConstraintDrivenIk( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Binding.Configuration = GripConfiguration.OneHanded;\n\t\tdocument.Binding.PrimaryHand.IsBound = true;\n\t\tdocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.5f, 0, 0 ) );\n\t\tdocument.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );\n\n\t\tvar skeleton = new HostSkeleton();\n\t\tskeleton.Add( Bone( \"root\", \"\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"arm_upper_R\", \"root\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"arm_lower_R\", \"arm_upper_R\", new Vector3( 1, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \"hand_R\", \"arm_lower_R\", new Vector3( 2, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \"bolt\", \"root\", new Vector3( 1.2f, 0.8f, 0 ) ) );\n\n\t\tvar clip = document.EnsureClip( WeaponClipRole.Idle );\n\t\tclip.Constraints.Add( new TimedConstraint\n\t\t{\n\t\t\tSourceControl = \"@primary_hand\",\n\t\t\tTargetBone = \"bolt\",\n\t\t\tStartTime = 0,\n\t\t\tEndTime = 1,\n\t\t\tMaintainOffset = false\n\t\t} );\n\t\tvar pose = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 0.5f );\n\t\tNear( report, new Vector3( 1.2f, 0.8f, 0 ), pose.Model[\"hand_R\"].Position, 0.002f, \"Constraint must drive the IK target before the arm solve.\" );\n\t}\n\n\tprivate static void TestIkDescendantPropagation( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Binding.Configuration = GripConfiguration.OneHanded;\n\t\tdocument.Binding.PrimaryHand.IsBound = true;\n\t\tdocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.2f, 1.2f, 0 ) );\n\t\tdocument.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );\n\n\t\tvar skeleton = new HostSkeleton();\n\t\tskeleton.Add( Bone( \"root\", \"\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"arm_upper_R\", \"root\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"arm_lower_R\", \"arm_upper_R\", new Vector3( 1, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \"hand_R\", \"arm_lower_R\", new Vector3( 2, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \"finger_R\", \"hand_R\", new Vector3( 2.5f, 0.2f, 0 ) ) );\n\t\tskeleton.Add( Bone( \"forearm_twist_R\", \"arm_lower_R\", new Vector3( 1.5f, 0, 0 ) ) );\n\n\t\tvar pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );\n\t\tvar fingerLocal = skeleton.GetBindLocal( skeleton.ByName[\"finger_R\"] );\n\t\tvar twistLocal = skeleton.GetBindLocal( skeleton.ByName[\"forearm_twist_R\"] );\n\t\tNear(\n\t\t\treport,\n\t\t\tpose.Model[\"hand_R\"].PointToWorld( fingerLocal.Position ),\n\t\t\tpose.Model[\"finger_R\"].Position,\n\t\t\t0.001f,\n\t\t\t\"Finger descendants must follow the solved hand.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tpose.Model[\"arm_lower_R\"].PointToWorld( twistLocal.Position ),\n\t\t\tpose.Model[\"forearm_twist_R\"].Position,\n\t\t\t0.001f,\n\t\t\t\"Twist descendants must follow the solved forearm.\" );\n\t}\n\n\tprivate static void TestConstraintMaintainedOffset( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Binding.Configuration = GripConfiguration.OneHanded;\n\t\tdocument.Binding.PrimaryHand.IsBound = true;\n\t\tdocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 1.5f, 0, 0 ) );\n\t\tdocument.Binding.PrimaryElbowPole.Transform = new Transform( new Vector3( 0, 0, 1 ) );\n\n\t\tvar skeleton = new HostSkeleton();\n\t\tskeleton.Add( Bone( \"root\", \"\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"arm_upper_R\", \"root\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"arm_lower_R\", \"arm_upper_R\", new Vector3( 1, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \"hand_R\", \"arm_lower_R\", new Vector3( 2, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \"bolt\", \"root\", new Vector3( 1, 0, 0 ) ) );\n\n\t\tvar clip = document.EnsureClip( WeaponClipRole.Idle );\n\t\tvar boltTrack = clip.EnsureTrack( \"bolt\" );\n\t\tWeaponAnimationMath.UpsertKey( boltTrack, 0, new Transform( new Vector3( 1, 0, 0 ) ) );\n\t\tWeaponAnimationMath.UpsertKey( boltTrack, 1, new Transform( new Vector3( 1.2f, 0, 0 ) ) );\n\t\tclip.Constraints.Add( new TimedConstraint\n\t\t{\n\t\t\tSourceControl = \"@primary_hand\",\n\t\t\tTargetBone = \"bolt\",\n\t\t\tStartTime = 0,\n\t\t\tEndTime = 1,\n\t\t\tMaintainOffset = true\n\t\t} );\n\n\t\tvar pose = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, 1 );\n\t\tNear( report, new Vector3( 1.7f, 0, 0 ), pose.Model[\"hand_R\"].Position, 0.002f, \"Maintain-offset constraints must preserve the start-frame hand offset.\" );\n\t}\n\n\tprivate static void TestHostSkeletonCache( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tHostSkeletonBuilder.ClearCache();\n\t\tvar document = ValidDocument();\n\t\tvar first = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );\n\t\tvar second = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\tReferenceEquals( first, second ),\n\t\t\t\"An unchanged document must reuse the cached host skeleton.\" );\n\n\t\t// Calibration nudges can be far below display precision, so the signature must compare\n\t\t// exact float bits rather than a rounded or formatted value.\n\t\tdocument.Calibration.PhysicalTransform =\n\t\t\tdocument.Calibration.PhysicalTransform.WithPosition(\n\t\t\t\tnew Vector3( 0.0000001f, 0, 0 ) );\n\t\tvar afterTinyMove = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ReferenceEquals( first, afterTinyMove ),\n\t\t\t\"A sub-precision calibration change must still invalidate the cached skeleton.\" );\n\n\t\tdocument.Rig.Bones[0].BindModelTransform =\n\t\t\tdocument.Rig.Bones[0].BindModelTransform.WithScale( 1.0000001f );\n\t\tvar afterBoneChange = HostSkeletonBuilder.BuildCached(\n\t\t\tdocument,\n\t\t\tincludeArmProfile: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ReferenceEquals( afterTinyMove, afterBoneChange ),\n\t\t\t\"A bone bind change must invalidate the cached skeleton.\" );\n\n\t\tdocument.Binding.PrimaryHand.Transform =\n\t\t\tdocument.Binding.PrimaryHand.Transform.WithPosition( new Vector3( 3, 2, 1 ) );\n\t\tvar afterBindingChange = HostSkeletonBuilder.BuildCached(\n\t\t\tdocument,\n\t\t\tincludeArmProfile: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ReferenceEquals( afterBoneChange, afterBindingChange ),\n\t\t\t\"A hand binding change must invalidate the cached skeleton.\" );\n\n\t\tvar reread = HostSkeletonBuilder.BuildCached( document, includeArmProfile: false );\n\t\tCheck(\n\t\t\treport,\n\t\t\tReferenceEquals( afterBindingChange, reread ),\n\t\t\t\"Rebuilding after a change must repopulate the cache rather than rebuild every call.\" );\n\t\tHostSkeletonBuilder.ClearCache();\n\t}\n\n\tprivate static void TestControllerHistoryAndClipboard( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( WeaponAnimationDocument.CreateDefault( \"History\" ) );\n\t\tcontroller.Mutate( \"Rename\", document => document.Name = \"Changed\" );\n\t\tCheck( report, controller.IsDirty && controller.CanUndo, \"A mutation must mark the document dirty and create undo history.\" );\n\t\tcontroller.Undo();\n\t\tEqual( report, \"History\", controller.Document.Name, \"Undo must restore the previous snapshot.\" );\n\t\tcontroller.Redo();\n\t\tEqual( report, \"Changed\", controller.Document.Name, \"Redo must restore the changed snapshot.\" );\n\t\tvar documentEvents = 0;\n\t\tvar poseEvents = 0;\n\t\tvar selectionEvents = 0;\n\t\tvar keySelectionEvents = 0;\n\t\tcontroller.DocumentChanged += () => documentEvents++;\n\t\tcontroller.PoseChanged += () => poseEvents++;\n\t\tcontroller.SelectionChanged += () => selectionEvents++;\n\t\tcontroller.KeySelectionChanged += () => keySelectionEvents++;\n\t\tcontroller.BeginContinuousEdit( \"Scrub name\" );\n\t\tcontroller.UpdateContinuousEdit( document => document.Name = \"Scrub A\" );\n\t\tcontroller.UpdateContinuousEdit( document => document.Name = \"Scrub B\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tdocumentEvents,\n\t\t\t\"A live scrub must not broadcast full document rebuilds while dragging.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tposeEvents,\n\t\t\t\"A live scrub must publish lightweight pose previews.\" );\n\t\tcontroller.EndContinuousEdit();\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tdocumentEvents,\n\t\t\t\"Completing a scrub must publish one consolidated document change.\" );\n\t\tcontroller.Undo();\n\t\tEqual( report, \"Changed\", controller.Document.Name, \"A continuous drag must collapse into one undo step.\" );\n\t\tcontroller.Redo();\n\t\tEqual( report, \"Scrub B\", controller.Document.Name, \"Redo must restore the final continuous-drag value.\" );\n\n\t\tvar clip = controller.Document.GetSelectedClip()!;\n\t\tvar track = clip.EnsureTrack( \"weapon_root\" );\n\t\tvar key = WeaponAnimationMath.UpsertKey( track, 0, new Transform( new Vector3( 1, 2, 3 ) ) );\n\t\tvar selectionBeforeKeys = selectionEvents;\n\t\tcontroller.SelectKeys( [key.Id], false );\n\t\tEqual(\n\t\t\treport,\n\t\t\tselectionBeforeKeys,\n\t\t\tselectionEvents,\n\t\t\t\"Key selection must not broadcast a control-selection rebuild.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tkeySelectionEvents > 0,\n\t\t\t\"Key selection must publish its dedicated lightweight event.\" );\n\t\tcontroller.CopySelectedKeys();\n\t\tcontroller.SetTimelineTime( 0.5f );\n\t\tcontroller.PasteKeys();\n\t\tclip = controller.Document.GetSelectedClip()!;\n\t\tEqual( report, 2, clip.EnsureTrack( \"weapon_root\" ).Keys.Count, \"Pasting keys must duplicate the clipboard payload.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\t0.5f,\n\t\t\tclip.EnsureTrack( \"weapon_root\" ).Keys.Max( x => x.Time ),\n\t\t\t0.0001f,\n\t\t\t\"Pasted keys must be offset to the playhead.\" );\n\n\t\tvar keyController = new WeaponAnimatorController();\n\t\tvar keyDocument = ValidDocument();\n\t\tkeyController.SetDocument( keyDocument );\n\t\tkeyController.SelectBone( \"weapon_root\" );\n\t\tkeyController.SetTimelineTime( 0.5f );\n\t\tkeyController.KeySelectedTransform();\n\t\tCheck(\n\t\t\treport,\n\t\t\tkeyController.Document.GetSelectedClip()!.Tracks\n\t\t\t\t.Single( current => current.Target == \"weapon_root\" )\n\t\t\t\t.Keys.Any( current => MathF.Abs( current.Time - 0.5f ) < 0.0001f ),\n\t\t\t\"The shared K/Add Key command must key a selected weapon bone.\" );\n\t}\n\n\tprivate static void TestValidation( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = ValidDocument();\n\t\tCheck( report, WeaponAnimationValidator.ValidateCalibration( document ).IsValid, \"A complete calibration should pass.\" );\n\t\tCheck( report, WeaponAnimationValidator.ValidateForGeneration( document ).IsValid, \"Idle-only generation should pass with action warnings.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationValidator.ValidateForGeneration( document ).Issues.Any( x =>\n\t\t\t\tx.Severity == ValidationSeverity.Warning && x.Code == \"clip.fallback\" ),\n\t\t\t\"Missing action clips must remain warnings.\" );\n\t\tdocument.Source.SourcePath = \"weapons/test/source.smd\";\n\t\tvar smdValidation = WeaponAnimationValidator.ValidateForGeneration( document );\n\t\tCheck(\n\t\t\treport,\n\t\t\tsmdValidation.Issues.Any( issue =>\n\t\t\t\tissue.Blocking && issue.Code == \"source.not_embeddable\" ),\n\t\t\t\"SMD projects must explain the ModelDoc generation limitation before Generate runs.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tsmdValidation.Issues.Any( issue =>\n\t\t\t\tissue.Code == \"source.not_embeddable\"\n\t\t\t\t&& issue.Message.Contains( \"SMD\", StringComparison.Ordinal ) ),\n\t\t\t\"The generation-format diagnostic must name the unsupported source extension.\" );\n\t\tdocument.Source.SourcePath = \"weapons/test/source.vmdl\";\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationValidator.ValidateForGeneration( document ).Issues.All( issue =>\n\t\t\t\tissue.Code != \"source.not_embeddable\" ),\n\t\t\t\"VMDL projects must pass source-format validation through the generated adapter path.\" );\n\t\tdocument.Source.SourcePath = \"weapons/test/source.fbx\";\n\t\tdocument.Calibration.Anchors.RemoveAll( anchor =>\n\t\t\tanchor.Kind is AnchorKind.RearBore or AnchorKind.FrontBore );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationValidator.ValidateCalibration( document ).IsValid,\n\t\t\t\"Auto-align markers must not block an already-oriented weapon.\" );\n\n\t\tdocument.Source.OriginalModelDimensions = Vector3.One;\n\t\tdocument.Calibration.PhysicalTransform =\n\t\t\tdocument.Calibration.PhysicalTransform.WithScale( 1 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationValidator.ValidateCalibration( document ).Issues.Any( issue =>\n\t\t\t\tissue.Code == \"scale.implausible\" ),\n\t\t\t\"Implausible-scale validation must use persisted source bounds without requiring a measurement.\" );\n\n\t\tdocument.Rig.Bones.Add( new WeaponBoneDefinition { Name = \"hand_R\" } );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\t!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,\n\t\t\t\t\"Facepunch-reserved weapon bone names must block calibration.\" );\n\n\t\t\tdocument.Rig.Bones.RemoveAt( document.Rig.Bones.Count - 1 );\n\t\t\tdocument.Rig.Bones[0].Name = \"root\";\n\t\t\tdocument.Rig.Bones[0].Classification = WeaponBoneClassification.WeaponRoot;\n\t\t\tdocument.Rig.RootBone = \"root\";\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tWeaponAnimationValidator.ValidateCalibration( document ).IsValid,\n\t\t\t\t\"A classified source root may use a reserved name before wrapper normalization.\" );\n\t}\n\n\tprivate static void TestGenerationOutputPaths( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar contentRoot = Path.Combine(\n\t\t\tPath.GetTempPath(),\n\t\t\t$\"weaponanim-output-{Guid.NewGuid():N}\",\n\t\t\t\"Assets\" );\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Output Test\" );\n\t\tvar defaultOutput = AssetGenerationService.ResolveOutputRootForContentRoot(\n\t\t\tdocument,\n\t\t\tcontentRoot );\n\t\tEqual(\n\t\t\treport,\n\t\t\tPath.GetFullPath( Path.Combine(\n\t\t\t\tcontentRoot,\n\t\t\t\t\"weapons\",\n\t\t\t\t\"output_test\",\n\t\t\t\t\"viewmodel\" ) ),\n\t\t\tdefaultOutput,\n\t\t\t\"Default generation output must resolve beneath Assets even before the folder exists.\" );\n\n\t\tdocument.Output.OutputFolder = \"/weapons/custom/viewmodel\";\n\t\tEqual(\n\t\t\treport,\n\t\t\tPath.GetFullPath( Path.Combine(\n\t\t\t\tcontentRoot,\n\t\t\t\t\"weapons\",\n\t\t\t\t\"custom\",\n\t\t\t\t\"viewmodel\" ) ),\n\t\t\tAssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),\n\t\t\t\"A leading asset slash must remain a project-relative output path.\" );\n\n\t\tdocument.Output.OutputFolder = \"../outside\";\n\t\tvar rejectedEscape = false;\n\t\ttry\n\t\t{\n\t\t\tAssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot );\n\t\t}\n\t\tcatch ( InvalidOperationException )\n\t\t{\n\t\t\trejectedEscape = true;\n\t\t}\n\t\tCheck(\n\t\t\treport,\n\t\t\trejectedEscape,\n\t\t\t\"Generation output must reject paths that escape the project's Assets folder.\" );\n\n\t\tdocument.Output.OutputFolder = \"C:/outside\";\n\t\tvar rejectedDrive = false;\n\t\ttry\n\t\t{\n\t\t\tAssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot );\n\t\t}\n\t\tcatch ( InvalidOperationException )\n\t\t{\n\t\t\trejectedDrive = true;\n\t\t}\n\t\tCheck(\n\t\t\treport,\n\t\t\trejectedDrive,\n\t\t\t\"Generation output must reject absolute drive paths on every host platform.\" );\n\n\t\tvar nestedOutputRoot = Path.Combine(\n\t\t\tPath.GetTempPath(),\n\t\t\t$\"weaponanim-nested-output-{Guid.NewGuid():N}\" );\n\t\ttry\n\t\t{\n\t\t\tAssetGenerationService.WriteTextSourcesForTests(\n\t\t\t\tnestedOutputRoot,\n\t\t\t\tnew Dictionary<string, string>\n\t\t\t\t{\n\t\t\t\t\t[\"materials/output_test_body.vmat\"] = \"fixture material\",\n\t\t\t\t\t[\"output_test_vm.vmdl\"] = \"fixture model\"\n\t\t\t\t} );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tFile.Exists( Path.Combine(\n\t\t\t\t\tnestedOutputRoot,\n\t\t\t\t\t\"materials\",\n\t\t\t\t\t\"output_test_body.vmat\" ) ),\n\t\t\t\t\"Generation must create parent directories for nested material sources.\" );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tif ( Directory.Exists( nestedOutputRoot ) )\n\t\t\t\tDirectory.Delete( nestedOutputRoot, true );\n\t\t}\n\n\t\tdocument.Output = null!;\n\t\tEqual(\n\t\t\treport,\n\t\t\tPath.GetFullPath( Path.Combine(\n\t\t\t\tcontentRoot,\n\t\t\t\t\"weapons\",\n\t\t\t\t\"output_test\",\n\t\t\t\t\"viewmodel\" ) ),\n\t\t\tAssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),\n\t\t\t\"Generation must repair missing output settings instead of throwing.\" );\n\t}\n\n\tprivate static void TestGeneratedFileRemoval( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar root = Path.Combine(\n\t\t\tPath.GetTempPath(),\n\t\t\t$\"weaponanim-removal-{Guid.NewGuid():N}\" );\n\t\tDirectory.CreateDirectory( root );\n\t\ttry\n\t\t{\n\t\t\tvar host = Path.Combine( root, \"weapon_host.vmdl\" );\n\t\t\tvar clip = Path.Combine( root, \"weapon_idle.dmx\" );\n\t\t\tvar graph = Path.Combine( root, \"weapon.vanmgrph\" );\n\t\t\tvar prefab = Path.Combine( root, \"v_weapon.prefab\" );\n\t\t\tforeach ( var file in new[] { host, clip, graph, prefab } )\n\t\t\t{\n\t\t\t\tFile.WriteAllText( file, \"generated\" );\n\t\t\t\tFile.WriteAllText( $\"{file}_c\", \"compiled\" );\n\t\t\t}\n\n\t\t\tAssetGenerationService.DeleteGeneratedFiles( [clip, host, graph, prefab] );\n\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\t!File.Exists( host ) && !File.Exists( $\"{host}_c\" ),\n\t\t\t\t\"Removing a generated asset must take its compiled artifact with it.\" );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\t!File.Exists( clip ) && !File.Exists( graph ) && !File.Exists( prefab ),\n\t\t\t\t\"Every listed generated file must be removed.\" );\n\n\t\t\t// The dependant .vmdl has to be gone before its .dmx sources, or the asset system\n\t\t\t// keeps recompiling a model whose animation dependencies stopped existing.\n\t\t\tFile.WriteAllText( host, \"generated\" );\n\t\t\tFile.WriteAllText( clip, \"generated\" );\n\t\t\tvar ordered = AssetGenerationService.OrderForRemoval( [clip, host] ).ToList();\n\t\t\tEqual(\n\t\t\t\treport,\n\t\t\t\thost,\n\t\t\t\tordered[0],\n\t\t\t\t\"Compiled dependants must be removed before the sources they consume.\" );\n\t\t\t\tvar lifecycleFiles = new[]\n\t\t\t\t{\n\t\t\t\t\t\"weapon_sequence_idle.dmx\",\n\t\t\t\t\t\"weapon_source_adapter.vmdl\",\n\t\t\t\t\t\"weapon_vm_bootstrap.vmdl\",\n\t\t\t\t\t\"weapon.vanmgrph\",\n\t\t\t\t\"weapon_vm.vmdl\",\n\t\t\t\t\"v_weapon.prefab\"\n\t\t\t};\n\t\t\tvar writeOrder = AssetGenerationService.OrderForWrite( lifecycleFiles ).ToList();\n\t\t\tEqual(\n\t\t\t\treport,\n\t\t\t\tstring.Join( \"|\", lifecycleFiles ),\n\t\t\t\tstring.Join( \"|\", writeOrder ),\n\t\t\t\t\"Generated sources must appear in dependency order so automatic compilation never observes a missing preview host.\" );\n\t\t\tvar removeOrder = AssetGenerationService.OrderForRemoval( lifecycleFiles ).ToList();\n\t\t\tEqual(\n\t\t\t\t\treport,\n\t\t\t\t\t\"v_weapon.prefab|weapon_vm.vmdl|weapon.vanmgrph|weapon_vm_bootstrap.vmdl|weapon_source_adapter.vmdl|weapon_sequence_idle.dmx\",\n\t\t\t\tstring.Join( \"|\", removeOrder ),\n\t\t\t\t\"Generated consumers must be removed in reverse dependency order.\" );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\t!AssetGenerationService.ShouldDeleteCreatedFileOnRollback(\n\t\t\t\t\t\"weapon_sprint.dmx\",\n\t\t\t\t\tpreviouslyOwned: true ),\n\t\t\t\t\"Rollback must retain a recreated owned DMX dependency needed by an older host.\" );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tAssetGenerationService.ShouldDeleteCreatedFileOnRollback(\n\t\t\t\t\t\"weapon_vm.vmdl\",\n\t\t\t\t\tpreviouslyOwned: true )\n\t\t\t\t&& AssetGenerationService.ShouldDeleteCreatedFileOnRollback(\n\t\t\t\t\t\"new_clip.dmx\",\n\t\t\t\t\tpreviouslyOwned: false ),\n\t\t\t\t\"Rollback must remove failed compiled consumers and newly introduced dependencies.\" );\n\n\t\t\tvar freshnessSource = Path.Combine( root, \"freshness.vmdl\" );\n\t\t\tvar freshnessCompiled = freshnessSource + \"_c\";\n\t\t\tFile.WriteAllText( freshnessSource, \"source\" );\n\t\t\tFile.WriteAllText( freshnessCompiled, \"compiled\" );\n\t\t\tvar now = DateTime.UtcNow;\n\t\t\tFile.SetLastWriteTimeUtc( freshnessSource, now );\n\t\t\tFile.SetLastWriteTimeUtc( freshnessCompiled, now.AddSeconds( 1 ) );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tAssetGenerationService.IsFreshCompiledArtifact(\n\t\t\t\t\tfreshnessSource,\n\t\t\t\t\tfreshnessCompiled ),\n\t\t\t\t\"A newly written compiled artifact must complete generation even while its managed Asset wrapper is stale.\" );\n\t\t\tFile.SetLastWriteTimeUtc( freshnessCompiled, now.AddSeconds( -10 ) );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\t!AssetGenerationService.IsFreshCompiledArtifact(\n\t\t\t\t\tfreshnessSource,\n\t\t\t\t\tfreshnessCompiled ),\n\t\t\t\t\"An artifact older than its regenerated source must never be accepted as compile success.\" );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tDirectory.Delete( root, true );\n\t\t}\n\t}\n\n\tprivate static void TestMaterialPipeline( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar embeddedMaterials = WeaponMaterialPipeline.MatchEmbeddedMaterialNamesForTests(\n\t\t\t[\"HK_P30L\", \"cartridge\"],\n\t\t\t[\"Material\", \"H&K_P30L\", \"cartridge\", \"cartridge_BaseColor\"] );\n\t\tCheck(\n\t\t\treport,\n\t\t\tembeddedMaterials.Contains( \"H&K_P30L\" )\n\t\t\t\t&& embeddedMaterials.Contains( \"cartridge\" )\n\t\t\t\t&& embeddedMaterials.Count == 2,\n\t\t\t\"Embedded FBX labels must preserve special characters when matching texture-set names.\" );\n\n\t\tvar discovered = WeaponMaterialPipeline.DiscoverForTests(\n\t\t\t[\n\t\t\t\t\"H&K_P30L.vmat\",\n\t\t\t\t\"cartridge.vmat\",\n\t\t\t\t\"materials/error.vmat\"\n\t\t\t],\n\t\t\t[\n\t\t\t\t\"/fixture/Textures/HK_P30L_BaseColor.png\",\n\t\t\t\t\"/fixture/Textures/HK_P30L_Normal_GL.png\",\n\t\t\t\t\"/fixture/Textures/HK_P30L_Normal_DX.png\",\n\t\t\t\t\"/fixture/Textures/HK_P30L_Roughness.png\",\n\t\t\t\t\"/fixture/Textures/HK_P30L_Metallic.png\",\n\t\t\t\t\"/fixture/Textures/cartridge_BaseColor.png\",\n\t\t\t\t\"/fixture/Textures/cartridge_Normal_DX.png\"\n\t\t\t] );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tdiscovered.Count,\n\t\t\t\"Nearby texture discovery must retain every FBX material slot.\" );\n\t\tvar pistol = discovered.Single( material => material.Name == \"H&K_P30L\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tpistol.FindTexture( WeaponTextureChannel.Normal )?.AssetPath\n\t\t\t\t.EndsWith( \"Normal_GL.png\", StringComparison.OrdinalIgnoreCase ) == true,\n\t\t\t\"S&box-compatible OpenGL normal maps must win when both GL and DX variants are available.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tpistol.FindTexture( WeaponTextureChannel.Metalness ) is not null\n\t\t\t\t&& discovered.Single( material => material.Name == \"cartridge\" )\n\t\t\t\t\t.FindTexture( WeaponTextureChannel.BaseColor ) is not null,\n\t\t\t\"Texture sets must be matched independently to their source material names.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdiscovered.All( material => !material.SourceMaterialPath.Equals(\n\t\t\t\t\"materials/error\",\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) ),\n\t\t\t\"The compiler error material must never become a generated weapon material slot.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdiscovered.All( material => !Path.HasExtension( material.SourceMaterialPath ) ),\n\t\t\t\"Stored source material labels must not look like GameResource dependencies.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"weaponanim_preview_cache/0123456789abcdef\",\n\t\t\tWeaponMaterialPipeline.LegalPreviewRelativeRootForTests(\n\t\t\t\t\"/fixture/Assets/.weaponanim-cache/0123456789abcdef\" ),\n\t\t\t\"Preview materials must use a legal non-hidden asset namespace.\" );\n\t\tvar originalRevision = WeaponMaterialPipeline.PreviewRevision( discovered );\n\t\tpistol.Textures[0].Sha256 = \"changed-image-hash\";\n\t\tvar changedRevision = WeaponMaterialPipeline.PreviewRevision( discovered );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!originalRevision.Equals( changedRevision, StringComparison.Ordinal ),\n\t\t\t\"A changed texture input must create a new immutable preview revision.\" );\n\t\tpistol.Textures[0].Sha256 = \"\";\n\n\t\tvar document = ValidDocument();\n\t\tdocument.Source.Materials = discovered.ToList();\n\t\tvar generated = WeaponMaterialPipeline.BuildOutputTextFiles(\n\t\t\tdocument,\n\t\t\t\"weapons/test_weapon/viewmodel\" );\n\t\tvar material = generated[\"materials/test_weapon_h_k_p30l.vmat\"];\n\t\tCheck(\n\t\t\treport,\n\t\t\tmaterial.Contains( \"F_SPECULAR 1\", StringComparison.Ordinal )\n\t\t\t\t&& material.Contains( \"F_METALNESS_TEXTURE 1\", StringComparison.Ordinal )\n\t\t\t\t&& material.Contains( \"TextureMetalness\", StringComparison.Ordinal )\n\t\t\t\t&& material.Contains(\n\t\t\t\t\t\"test_weapon_h_k_p30l_metalness.png\",\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\"Generated weapon VMATs must enable specular and mapped metalness.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgenerated.Keys.Count( path => path.EndsWith(\n\t\t\t\t\".vtex\",\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) ) == 0\n\t\t\t\t&& material.Contains(\n\t\t\t\t\t\"test_weapon_h_k_p30l_color.png\",\n\t\t\t\t\tStringComparison.Ordinal )\n\t\t\t\t&& material.Contains(\n\t\t\t\t\t\"test_weapon_h_k_p30l_normal.png\",\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\"VMATs must reference image inputs directly so S&box can build native generated VTEX resources.\" );\n\t\tdocument.Source.NeedsModelDocWrapper = true;\n\t\tpistol.PreviewMaterialPath =\n\t\t\t\".weaponanim-cache/fixture/materials/h_k_p30l.vmat\";\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponMaterialPipeline.RequiresPreviewRefresh( document ),\n\t\t\t\"Legacy hidden preview material paths must force a safe material refresh.\" );\n\t\tforeach ( var binding in discovered.Where( binding => binding.HasUsableTextures ) )\n\t\t{\n\t\t\tbinding.PreviewMaterialPath =\n\t\t\t\t$\"weaponanim_preview_cache/fixture/revision/materials/{binding.OutputName}.vmat\";\n\t\t}\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponMaterialPipeline.RequiresPreviewRefresh( document ),\n\t\t\t\"Legal compiled preview material paths must not refresh repeatedly.\" );\n\t\tvar serializedDocument = Json.Serialize( document );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!serializedDocument.Contains(\n\t\t\t\t\"PreviewMaterialPath\",\n\t\t\t\tStringComparison.Ordinal )\n\t\t\t\t&& !serializedDocument.Contains(\n\t\t\t\t\t\"H&K_P30L.vmat\",\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ),\n\t\t\t\"Transient preview VMATs and source slot extensions must stay out of .wepanim serialization.\" );\n\n\t\tvar legacyMaterialDocument = WeaponAnimationDocument.CreateDefault();\n\t\tlegacyMaterialDocument.Source.Materials =\n\t\t[\n\t\t\tnew SourceMaterialBinding\n\t\t\t{\n\t\t\t\tSourceMaterialPath = \"cartridge.vmat\",\n\t\t\t\tName = \"cartridge\",\n\t\t\t\tOutputName = \"cartridge\"\n\t\t\t}\n\t\t];\n\t\tvar materialMigration = WeaponAnimationMigration.MigrateAndRepair(\n\t\t\tlegacyMaterialDocument );\n\t\tCheck(\n\t\t\treport,\n\t\t\tmaterialMigration.RepairedMaterialMetadata\n\t\t\t\t&& legacyMaterialDocument.Source.Materials[0].SourceMaterialPath\n\t\t\t\t\t.Equals( \"cartridge\", StringComparison.Ordinal ),\n\t\t\t\"Opening an existing project must remove false VMAT dependencies from source slot metadata.\" );\n\n\t\tvar recoveredCandidate = WeaponSourceImporter.SelectRecoveryCandidateForTests(\n\t\t[\n\t\t\tnew(\n\t\t\t\t\"/preview/newer-uncompiled/models/source_abc_textured.vmdl\",\n\t\t\t\tnew DateTime( 2026, 7, 28, 20, 0, 0, DateTimeKind.Utc ),\n\t\t\t\tfalse,\n\t\t\t\ttrue ),\n\t\t\tnew(\n\t\t\t\t\"/preview/legacy/models/source_abc_textured.vmdl\",\n\t\t\t\tnew DateTime( 2026, 7, 28, 19, 0, 0, DateTimeKind.Utc ),\n\t\t\t\ttrue,\n\t\t\t\tfalse ),\n\t\t\tnew(\n\t\t\t\t\"/preview/versioned/models/source_abc_textured.vmdl\",\n\t\t\t\tnew DateTime( 2026, 7, 28, 18, 0, 0, DateTimeKind.Utc ),\n\t\t\t\ttrue,\n\t\t\t\ttrue )\n\t\t] );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\"/preview/versioned/models/source_abc_textured.vmdl\",\n\t\t\trecoveredCandidate,\n\t\t\t\"Missing saved source wrappers must recover to a compiled immutable preview revision.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(\n\t\t\t\t\"weaponanim_preview_cache/document/source.vmdl\",\n\t\t\t\t\"weaponanim_preview_cache/document/source.vmdl\" )\n\t\t\t\t&& WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(\n\t\t\t\t\t\"weaponanim_preview_cache/document/repaired.vmdl\",\n\t\t\t\t\t\"weaponanim_preview_cache/document/source.vmdl\" ),\n\t\t\t\"A failed source load must not rebuild the private scene every frame, \"\n\t\t\t\t+ \"but a repaired path must trigger one rebuild.\" );\n\n\t\tvar remaps = WeaponMaterialPipeline.OutputRemaps(\n\t\t\tdocument,\n\t\t\t\"weapons/test_weapon/viewmodel\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tremaps.Count,\n\t\t\t\"Final generation must preserve separate material-slot remaps.\" );\n\t\tvar host = ModelDocWriter.WriteHost(\n\t\t\t\"host_reference.dmx\",\n\t\t\t[],\n\t\t\t\"\",\n\t\t\t[\"weapon_root\"],\n\t\t\tnew HostWeaponMesh(\n\t\t\t\t\"source.fbx\",\n\t\t\t\t\"weapon_root\",\n\t\t\t\tTransform.Zero,\n\t\t\t\t[],\n\t\t\t\tremaps ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\thost.Contains( \"use_global_default = false\", StringComparison.Ordinal )\n\t\t\t\t&& !host.Contains( \"use_global_default = true\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"from = \\\"H&K_P30L.vmat\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"from = \\\"cartridge.vmat\\\"\", StringComparison.Ordinal ),\n\t\t\t\"Weapon ModelDocs must use per-slot remaps with global material override disabled.\" );\n\t}\n\n\tprivate static void TestRebase( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Rig.RootBone = \"weapon_root\";\n\t\tvar idle = document.EnsureClip( WeaponClipRole.Idle );\n\t\tvar rootTrack = idle.EnsureTrack( \"weapon_root\" );\n\t\tWeaponAnimationMath.UpsertKey( rootTrack, 0, new Transform( new Vector3( 2, 0, 0 ) ) );\n\t\tvar previous = new CalibrationSnapshot\n\t\t{\n\t\t\tPhysicalTransform = Transform.Zero,\n\t\t\tFramingTransform = Transform.Zero\n\t\t};\n\t\tdocument.Calibration.PhysicalTransform = new Transform( new Vector3( 10, 0, 0 ) );\n\t\tCalibrationRebaser.RebaseAnimationData( document, previous );\n\t\tNear( report, 12, rootTrack.Keys[0].Position.x, 0.001f, \"Root keys must retain their placement-relative offset.\" );\n\t}\n\n\tprivate static void TestDmxOutput( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar skeleton = new HostSkeleton();\n\t\tskeleton.Add( Bone( \"root\", \"\", Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \"weapon_root\", \"root\", Vector3.Forward ) );\n\t\tvar first = DmxWriter.WriteReference( skeleton );\n\t\tvar second = DmxWriter.WriteReference( skeleton );\n\n\t\tCheck( report, first.StartsWith( \"<!-- dmx encoding keyvalues2 4 format model 22 -->\" ), \"Host reference must use ModelDoc's supported DMX model format.\" );\n\t\tCheck( report, first.Contains( \"\\\"name\\\" \\\"string\\\" \\\"weapon_root\\\"\" ), \"Host reference must include every skeleton bone.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfirst.Contains( \"\\\"element\\\" \\\"\" + DmxJointIdForTest( 0 ) + \"\\\",\" ),\n\t\t\t\"DMX element array entries must be comma-delimited.\" );\n\t\tvar blendIndices = first[first.IndexOf( \"\\\"blendindices$0\\\" \\\"int_array\\\"\", StringComparison.Ordinal )..];\n\t\tCheck(\n\t\t\treport,\n\t\t\tblendIndices.Contains( \"\\t\\t\\\"1\\\",\\n\\t\\t\\\"1\\\",\\n\\t\\t\\\"1\\\"\\n\", StringComparison.Ordinal ),\n\t\t\t\"The carrier mesh must reference every host bone so ModelDoc cannot cull the skeleton.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfirst.Contains( \"\\t\\t\\t\\t\\\"3\\\",\\n\\t\\t\\t\\t\\\"4\\\",\\n\\t\\t\\t\\t\\\"5\\\",\\n\\t\\t\\t\\t\\\"-1\\\"\\n\", StringComparison.Ordinal ),\n\t\t\t\"The carrier mesh must emit one triangle per host bone.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfirst.Contains( \"materials/tools/toolsinvisible.vmat\", StringComparison.Ordinal ),\n\t\t\t\"The bone-retention carrier must use an invisible material.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfirst.Contains( \"\\\"forwardParity\\\" \\\"int\\\" \\\"1\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& !first.Contains( \"\\\"forwardParity\\\" \\\"int\\\" \\\"-2\\\"\", StringComparison.Ordinal ),\n\t\t\t\"Reference DMX must use the Source 2 Z-up axis parity expected by ModelDoc.\" );\n\t\tEqual( report, first, second, \"DMX host references must be deterministic.\" );\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Binding.Configuration = GripConfiguration.OneHanded;\n\t\tvar clip = document.EnsureClip( WeaponClipRole.Idle );\n\t\tclip.Duration = 1;\n\t\tclip.SampleRate = 30;\n\t\tvar animation = DmxWriter.WriteAnimation( document, skeleton, clip );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \"\\\"DmeChannelsClip\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& animation.Contains( \"\\\"DmeVector3LogLayer\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& animation.Contains( \"\\\"DmeQuaternionLogLayer\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& animation.Contains( \"\\\"DmeFloatLogLayer\\\"\", StringComparison.Ordinal ),\n\t\t\t\"DMX animation output must contain position, rotation, and scale channels.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \"\\\"DmeJoint\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& !animation.Contains( \"\\\"DmeDag\\\"\", StringComparison.Ordinal ),\n\t\t\t\"Animation skeleton entries must be Source 2 joints rather than generic DAG nodes.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \"\\\"DmeTransformList\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& animation.Contains( \"\\\"baseStates\\\" \\\"element_array\\\"\", StringComparison.Ordinal ),\n\t\t\t\"Animation DMX must include a bind transform list for ModelDoc sequence import.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \"\\\"mode\\\" \\\"int\\\" \\\"1\\\"\", StringComparison.Ordinal ),\n\t\t\t\"Animation channels must use the Source 2 exporter channel mode.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \"\\\"jointList\\\" \\\"element_array\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& animation.Contains(\n\t\t\t\t\t\"\\\"element\\\" \\\"\" + DmxAnimationJointIdForTest( clip, 0 ) + \"\\\"\",\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\"Animation DMX must register every animated joint with its model.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \"\\t\\t\\\"1\\\"\\n\", StringComparison.Ordinal ),\n\t\t\t\"A one-second animation must include its final sample time.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!animation.Contains( \"NaN\", StringComparison.OrdinalIgnoreCase )\n\t\t\t\t&& !animation.Contains( \"Infinity\", StringComparison.OrdinalIgnoreCase ),\n\t\t\t\"DMX animation output must contain finite transforms.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \"\\\"forwardParity\\\" \\\"int\\\" \\\"1\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& !animation.Contains( \"\\\"forwardParity\\\" \\\"int\\\" \\\"-2\\\"\", StringComparison.Ordinal ),\n\t\t\t\"Animation DMX must use the same Source 2 axis system as its host reference.\" );\n\t\tEqual(\n\t\t\treport,\n\t\t\tanimation,\n\t\t\tDmxWriter.WriteAnimation( document, skeleton, clip ),\n\t\t\t\"DMX animation output must be deterministic.\" );\n\n\t\tvar scaledSkeleton = new HostSkeleton();\n\t\tscaledSkeleton.Add( new HostBone\n\t\t{\n\t\t\tName = \"root\",\n\t\t\tBindModelTransform = Transform.Zero,\n\t\t\tBindLocalTransform = Transform.Zero,\n\t\t\tHasExplicitBindLocal = true\n\t\t} );\n\t\tscaledSkeleton.Add( new HostBone\n\t\t{\n\t\t\tName = \"weapon_root\",\n\t\t\tParentName = \"root\",\n\t\t\tBindModelTransform = new Transform(\n\t\t\t\tVector3.Zero,\n\t\t\t\tRotation.Identity,\n\t\t\t\tVector3.One * 0.56f ),\n\t\t\tBindLocalTransform = new Transform(\n\t\t\t\tVector3.Zero,\n\t\t\t\tRotation.Identity,\n\t\t\t\tVector3.One * 0.56f ),\n\t\t\tHasExplicitBindLocal = true,\n\t\t\tIsWeaponBone = true\n\t\t} );\n\t\tscaledSkeleton.Add( new HostBone\n\t\t{\n\t\t\tName = \"hammer\",\n\t\t\tParentName = \"weapon_root\",\n\t\t\tBindModelTransform = new Transform(\n\t\t\t\tnew Vector3( 0, -5.75f, 0.2f ) * 0.56f ),\n\t\t\tBindLocalTransform = new Transform( new Vector3( 0, -5.75f, 0.2f ) ),\n\t\t\tHasExplicitBindLocal = true,\n\t\t\tIsWeaponBone = true\n\t\t} );\n\t\tvar hammerTrack = clip.EnsureTrack( \"hammer\" );\n\t\tvar hammerRotation = Rotation.FromPitch( 45 );\n\t\tWeaponAnimationMath.UpsertKey(\n\t\t\thammerTrack,\n\t\t\t0,\n\t\t\tnew Transform( new Vector3( 0, -5.75f, 0.2f ), hammerRotation ) );\n\t\tvar scaledPose = AnimationPoseEvaluator.Evaluate(\n\t\t\tdocument,\n\t\t\tscaledSkeleton,\n\t\t\tclip,\n\t\t\t0 );\n\t\tvar exportedPose = DmxWriter.BuildCompilerPoseLocals(\n\t\t\tscaledSkeleton,\n\t\t\tscaledPose.Local );\n\t\tvar exportedRoot = exportedPose[\"weapon_root\"];\n\t\tvar exportedHammer = exportedPose[\"hammer\"];\n\t\tNear(\n\t\t\treport,\n\t\t\tVector3.One,\n\t\t\texportedRoot.Scale,\n\t\t\t0.0001f,\n\t\t\t\"Animation export must use ModelDoc's scale-one compiled bind space.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\tnew Vector3( 0, -5.75f, 0.2f ) * 0.56f,\n\t\t\texportedHammer.Position,\n\t\t\t0.0001f,\n\t\t\t\"Rotating a weapon child must use the physical scale-baked mesh pivot in compiled bind space.\" );\n\t\tNear(\n\t\t\treport,\n\t\t\thammerRotation.Forward,\n\t\t\texportedHammer.Rotation.Forward,\n\t\t\t0.0001f,\n\t\t\t\"Rotating a weapon child must retain its authored local rotation in compiled bind space.\" );\n\t\tvar scaledAnimation = DmxWriter.WriteAnimation(\n\t\t\tdocument,\n\t\t\tscaledSkeleton,\n\t\t\tclip );\n\t\tvar scaledReference = DmxWriter.WriteReference( scaledSkeleton );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!scaledAnimation.Contains(\n\t\t\t\t\"\\\"scale\\\" \\\"float\\\" \\\"0.56\\\"\",\n\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\"Animation bind declarations must not reintroduce source scale after ModelDoc bakes it into the host.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tscaledReference.Contains(\n\t\t\t\t\"\\\"position\\\" \\\"vector3\\\" \\\"0 -3.22 0.112\\\"\",\n\t\t\t\tStringComparison.Ordinal )\n\t\t\t\t&& scaledAnimation.Contains(\n\t\t\t\t\t\"\\\"position\\\" \\\"vector3\\\" \\\"0 -3.22 0.112\\\"\",\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\"Reference and animation skeletons must share the scale-baked physical pivot of rotating weapon children.\" );\n\n\t\tdocument.Manifest.Files.Add( new GeneratedFileRecord\n\t\t{\n\t\t\tRelativePath = \"generated_sequence.dmx\"\n\t\t} );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!Json.Serialize( document ).Contains( \"\\\"Manifest\\\"\", StringComparison.Ordinal ),\n\t\t\t\"The creative document must not serialize generated filenames as resource dependencies.\" );\n\t\tvar wrapper = ModelDocWriter.WriteSourceWrapper( \"weapon.fbx\", \"root\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\twrapper.Contains( \"original_bone_name = \\\"root\\\"\" )\n\t\t\t\t&& wrapper.Contains( \"new_bone_name = \\\"weapon_root\\\"\" ),\n\t\t\t\"Source wrappers must normalize the selected weapon root.\" );\n\t\tvar host = ModelDocWriter.WriteHost(\n\t\t\t\"host_reference.dmx\",\n\t\t\t[],\n\t\t\t\"weapon.vanmgrph\",\n\t\t\tskeleton.Bones.Select( bone => bone.Name ),\n\t\t\tnew HostWeaponMesh(\n\t\t\t\t\"weapon.fbx\",\n\t\t\t\t\"root\",\n\t\t\t\tnew Transform( Vector3.Zero, Rotation.Identity, Vector3.One * 0.6f ),\n\t\t\t\t[],\n\t\t\t\t[\n\t\t\t\t\tnew HostMaterialRemap(\n\t\t\t\t\t\t\"frame.vmat\",\n\t\t\t\t\t\t\"weapons/test/materials/frame.vmat\" )\n\t\t\t\t] ),\n\t\t\t[\n\t\t\t\tnew HostAttachment(\n\t\t\t\t\t\"muzzle\",\n\t\t\t\t\t\"weapon_root\",\n\t\t\t\t\tVector3.Forward * 10,\n\t\t\t\t\tRotation.Identity )\n\t\t\t] );\n\t\tCheck(\n\t\t\treport,\n\t\t\thost.Contains( \"target_bone = \\\"weapon_root\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"do_not_discard = true\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"filename = \\\"weapon.fbx\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"import_scale = 0.6\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"anim_graph_name = \\\"weapon.vanmgrph\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"use_global_default = false\", StringComparison.Ordinal )\n\t\t\t\t&& !host.Contains( \"use_global_default = true\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"from = \\\"frame.vmat\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"from = \\\"materials/tools/toolsinvisible.vmat\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"_class = \\\"Attachment\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& host.Contains( \"name = \\\"muzzle\\\"\", StringComparison.Ordinal ),\n\t\t\t\"Host ModelDocs must preserve generated bones, safely handle imported materials, and contain the visible weapon, graph, and attachments.\" );\n\t\tvar skeletonOnlyHost = ModelDocWriter.WriteHost(\n\t\t\t\"host_reference.dmx\",\n\t\t\t[],\n\t\t\t\"\",\n\t\t\tskeleton.Bones.Select( bone => bone.Name ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\tskeletonOnlyHost.Contains( \"use_global_default = false\", StringComparison.Ordinal ),\n\t\t\t\"A skeleton-only host must retain the invisible carrier material without substitution.\" );\n\t}\n\n\tprivate static void TestFilteredSourceWrapper( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar wrapper = ModelDocWriter.WriteSourceWrapper(\n\t\t\t\"weapons/test/source.fbx\",\n\t\t\t\"Armature\",\n\t\t\t[\"foreign_arm\", \"foreign_camera\"] );\n\t\tCheck( report, wrapper.Contains( \"_class = \\\"RenameBone\\\"\" ), \"A tool-owned source wrapper must normalize the root without modifying the original source.\" );\n\t\tCheck( report, wrapper.Contains( \"_class = \\\"RemoveBoneAndChildren\\\"\" ), \"A filtered source wrapper must remove excluded branch roots.\" );\n\t\tCheck( report, wrapper.Contains( \"\\\"foreign_arm\\\"\" ) && wrapper.Contains( \"\\\"foreign_camera\\\"\" ), \"Every excluded branch root must be emitted deterministically.\" );\n\t\tvar vmdl = $\"{ModelDocWriter.Header}\\n{{ rootNode = {{ _class = \\\"RootNode\\\" children = [ ] }} }}\";\n\t\tvar adapted = ModelDocWriter.WriteVmdlSourceAdapter( vmdl, \"root\", [\"foreign_arm\"] );\n\t\tCheck(\n\t\t\treport,\n\t\t\tadapted.Contains( \"_class = \\\"ModelModifierList\\\"\" )\n\t\t\t\t&& adapted.Contains( \"original_bone_name = \\\"root\\\"\" )\n\t\t\t\t&& adapted.Contains( \"\\\"foreign_arm\\\"\" ),\n\t\t\t\"VMDL inputs must receive the same tool-owned root normalization and branch filtering.\" );\n\t}\n\n\tprivate static void TestGenerationSourceAdapters( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar source = $$\"\"\"\n\t\t\t{{ModelDocWriter.Header}}\n\t\t\t{\n\t\t\t\trootNode =\n\t\t\t\t{\n\t\t\t\t\t_class = \"RootNode\"\n\t\t\t\t\tchildren =\n\t\t\t\t\t[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\tfilename = \"receiver.fbx\"\n\t\t\t\t\t\t\timport_translation = [ 2, 0, 0 ]\n\t\t\t\t\t\t\timport_rotation = [ 0, 0, 0 ]\n\t\t\t\t\t\t\timport_scale = 1\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\tfilename = \"magazine.fbx\"\n\t\t\t\t\t\t\timport_translation = [ 0, 2, 0 ]\n\t\t\t\t\t\t\timport_rotation = [ 0, 0, 0 ]\n\t\t\t\t\t\t\timport_scale = 1\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t}\n\t\t\t\"\"\";\n\t\tvar adapted = ModelDocWriter.WriteVmdlSourceAdapter(\n\t\t\tsource,\n\t\t\t\"root\",\n\t\t\t[\"foreign_arm\"],\n\t\t\tnew Transform( new Vector3( 10, 0, 0 ), Rotation.Identity, 0.5f ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\tCount( adapted, \"import_scale = 0.5\" ) == 2\n\t\t\t\t&& adapted.Contains( \"import_translation = [ 11, 0, 0 ]\", StringComparison.Ordinal )\n\t\t\t\t&& adapted.Contains( \"import_translation = [ 10, 1, 0 ]\", StringComparison.Ordinal )\n\t\t\t\t&& adapted.Contains( \"original_bone_name = \\\"root\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& adapted.Contains( \"\\\"foreign_arm\\\"\", StringComparison.Ordinal ),\n\t\t\t\"A VMDL adapter must apply calibration to every render mesh while preserving filtering.\" );\n\n\t\tvar baseHost = ModelDocWriter.WriteHost(\n\t\t\t\"reference.dmx\",\n\t\t\t[],\n\t\t\t\"\",\n\t\t\t[\"weapon_root\"],\n\t\t\tbaseModelPath: \"weapons/test/source_adapter.vmdl\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tbaseHost.Contains(\n\t\t\t\t\"base_model_name = \\\"weapons/test/source_adapter.vmdl\\\"\",\n\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\"Generated hosts must be able to derive their visible mesh from a VMDL adapter.\" );\n\n\t\tvar temporary = Path.Combine(\n\t\t\tPath.GetTempPath(),\n\t\t\t$\"weaponanim-source-{Guid.NewGuid():N}.vmdl\" );\n\t\tFile.WriteAllText( temporary, source );\n\t\ttry\n\t\t{\n\t\t\tvar document = ValidDocument();\n\t\t\tdocument.Source.SourcePath = temporary;\n\t\t\tdocument.Source.CompiledModelPath = temporary;\n\t\t\tdocument.Calibration.PhysicalTransform =\n\t\t\t\tnew Transform( Vector3.Zero, Rotation.Identity, 0.6f );\n\t\t\tvar progress = new List<GenerationProgress>();\n\t\t\tvar generated = AssetGenerationService.BuildFiles(\n\t\t\t\tdocument,\n\t\t\t\tHostSkeletonBuilder.Build( document, includeArmProfile: false ),\n\t\t\t\t\"weapons/test_weapon/viewmodel\",\n\t\t\t\tprogress.Add );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tgenerated.ContainsKey( \"test_weapon_source_adapter.vmdl\" )\n\t\t\t\t\t&& generated[\"test_weapon_vm.vmdl\"].Contains(\n\t\t\t\t\t\t\"base_model_name = \\\"weapons/test_weapon/viewmodel/test_weapon_source_adapter.vmdl\\\"\",\n\t\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\t\"VMDL source projects must generate a persistent calibrated adapter.\" );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tprogress.Any( item =>\n\t\t\t\t\titem.Stage == \"Sequences\"\n\t\t\t\t\t&& item.Completed == 1\n\t\t\t\t\t&& item.Total == 1 ),\n\t\t\t\t\"Generation must report deterministic per-sequence progress.\" );\n\t\t\tusing var cancellation = new System.Threading.CancellationTokenSource();\n\t\t\tcancellation.Cancel();\n\t\t\tvar cancelled = false;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tAssetGenerationService.BuildFiles(\n\t\t\t\t\tdocument,\n\t\t\t\t\tHostSkeletonBuilder.Build( document, includeArmProfile: false ),\n\t\t\t\t\t\"weapons/test_weapon/viewmodel\",\n\t\t\t\t\tcancellationToken: cancellation.Token );\n\t\t\t}\n\t\t\tcatch ( OperationCanceledException )\n\t\t\t{\n\t\t\t\tcancelled = true;\n\t\t\t}\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tcancelled,\n\t\t\t\t\"Generation must honor cancellation before assembling or replacing output files.\" );\n\t\t\tcancelled = false;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tDmxWriter.WriteAnimation(\n\t\t\t\t\tdocument,\n\t\t\t\t\tHostSkeletonBuilder.Build( document, includeArmProfile: false ),\n\t\t\t\t\tdocument.EnsureClip( WeaponClipRole.Idle ),\n\t\t\t\t\tcancellation.Token );\n\t\t\t}\n\t\t\tcatch ( OperationCanceledException )\n\t\t\t{\n\t\t\t\tcancelled = true;\n\t\t\t}\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tcancelled,\n\t\t\t\t\"DMX frame sampling must observe cancellation inside the worker-safe generation path.\" );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tFile.Delete( temporary );\n\t\t}\n\t}\n\n\tprivate static string DmxJointIdForTest( int index )\n\t{\n\t\tvar bytes = System.Security.Cryptography.SHA256.HashData(\n\t\t\tSystem.Text.Encoding.UTF8.GetBytes( $\"SboxWeaponAnimator.DmxReference:joint:{index}\" ) );\n\t\treturn new Guid( bytes.AsSpan( 0, 16 ) ).ToString();\n\t}\n\n\tprivate static string DmxAnimationJointIdForTest(\n\t\tWeaponAnimationClip clip,\n\t\tint index )\n\t{\n\t\tvar key =\n\t\t\t$\"SboxWeaponAnimator.DmxReference:animation:{clip.Id}:joint:{index}\";\n\t\tvar bytes = System.Security.Cryptography.SHA256.HashData(\n\t\t\tSystem.Text.Encoding.UTF8.GetBytes( key ) );\n\t\treturn new Guid( bytes.AsSpan( 0, 16 ) ).ToString();\n\t}\n\n\tprivate static void TestDeterministicOutput( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = ValidDocument();\n\t\tvar idle = document.EnsureClip( WeaponClipRole.Idle );\n\t\tvar originalCulture = CultureInfo.CurrentCulture;\n\t\ttry\n\t\t{\n\t\t\tCultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( \"fr-FR\" );\n\t\t\t\tvar graphFrench = AnimGraphWriter.Write( document, \"weapons/test/host.vmdl\" );\n\t\t\t\tvar modelFrench = ModelDocWriter.WriteHost(\n\t\t\t\t\t\"host_reference.dmx\",\n\t\t\t\t\t[(idle, \"idle.dmx\")],\n\t\t\t\t\t\"weapon.vanmgrph\",\n\t\t\t\t\t[\"root\", \"weapon_root\"] );\n\t\t\tvar prefabFrench = PrefabWriter.Write( document, \"host.vmdl\" );\n\n\t\t\tCultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( \"en-US\" );\n\t\t\tEqual( report, graphFrench, AnimGraphWriter.Write( document, \"weapons/test/host.vmdl\" ), \"AnimGraph output must be culture-independent.\" );\n\t\t\tEqual(\n\t\t\t\t\treport,\n\t\t\t\t\tmodelFrench,\n\t\t\t\t\tModelDocWriter.WriteHost(\n\t\t\t\t\t\t\"host_reference.dmx\",\n\t\t\t\t\t\t[(idle, \"idle.dmx\")],\n\t\t\t\t\t\t\"weapon.vanmgrph\",\n\t\t\t\t\t\t[\"root\", \"weapon_root\"] ),\n\t\t\t\t\"ModelDoc output must be culture-independent.\" );\n\t\t\tEqual( report, prefabFrench, PrefabWriter.Write( document, \"host.vmdl\" ), \"Prefab output must be culture-independent.\" );\n\t\t\tEqual( report, AnimGraphWriter.Id( \"node:Root\" ), AnimGraphWriter.Id( \"node:Root\" ), \"Deterministic graph IDs must be stable.\" );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tCultureInfo.CurrentCulture = originalCulture;\n\t\t}\n\t}\n\n\tprivate static void TestAnimGraphTagsAndFallbacks( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = ValidDocument();\n\t\tvar idle = document.EnsureClip( WeaponClipRole.Idle );\n\t\tidle.Tags.Add( new AnimationTag\n\t\t{\n\t\t\tName = \"attack_discouraged\",\n\t\t\tKind = AnimationTagKind.Range,\n\t\t\tStartTime = 0.2f,\n\t\t\tEndTime = 0.6f\n\t\t} );\n\t\tvar graph = AnimGraphWriter.Write( document, \"host.vmdl\" );\n\t\tCheck( report, graph.Contains( \"_class = \\\"CAnimTagSpan\\\"\" ), \"Authored tags must become sequence tag spans.\" );\n\t\tCheck( report, graph.Contains( \"m_fStartCycle = 0.2\" ), \"Tag start time must be normalized to sequence cycle.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tCount( graph, \"m_sequenceName = \\\"idle\\\"\" ) > 1,\n\t\t\t\"Missing action clips must use Idle sequence fallbacks.\" );\n\t\tCheck( report, graph.Contains( \"m_name = \\\"b_attack\\\"\" ), \"Facepunch firearm parameters must be exposed.\" );\n\t\tCheck( report, graph.Contains( \"m_name = \\\"reload_increment\\\"\" ), \"Standard reload tags must be declared.\" );\n\n\t\tvar skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );\n\t\tvar generated = AssetGenerationService.BuildFiles(\n\t\t\tdocument,\n\t\t\tskeleton,\n\t\t\t\"weapons/test_weapon/viewmodel\" );\n\t\tvar finalHost = generated[\"test_weapon_vm.vmdl\"];\n\t\tvar bootstrapHost = generated[\"test_weapon_vm_bootstrap.vmdl\"];\n\t\tvar generatedGraph = generated[\"test_weapon.vanmgrph\"];\n\t\tCheck(\n\t\t\treport,\n\t\t\tfinalHost.Contains(\n\t\t\t\t\"anim_graph_name = \\\"weapons/test_weapon/viewmodel/test_weapon.vanmgrph\\\"\",\n\t\t\t\tStringComparison.Ordinal )\n\t\t\t\t&& bootstrapHost.Contains( \"anim_graph_name = \\\"\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& generatedGraph.Contains(\n\t\t\t\t\t\"m_previewModels = [ \\\"weapons/test_weapon/viewmodel/test_weapon_vm_bootstrap.vmdl\\\", ]\",\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\"Generation must keep a permanent graph-free preview host while the final host always links its AnimGraph.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgenerated.ContainsKey( \"test_weapon_sequence_idle.dmx\" )\n\t\t\t\t&& !generated.ContainsKey( \"test_weapon_idle.dmx\" )\n\t\t\t\t&& !generated.ContainsKey( \"test_weapon_sequence_fire.dmx\" ),\n\t\t\t\"Generation must emit authored sequences only and leave missing action roles on Idle fallbacks.\" );\n\n\t\tvar custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tcustom.Name = \"Mechanical Check\";\n\t\tcustom.Readiness = ClipReadiness.Draft;\n\t\tdocument.Clips.Add( custom );\n\t\tWeaponAnimationNames.RepairCustomSequenceNames( document );\n\t\tgenerated = AssetGenerationService.BuildFiles(\n\t\t\tdocument,\n\t\t\tskeleton,\n\t\t\t\"weapons/test_weapon/viewmodel\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgenerated.ContainsKey(\n\t\t\t\t$\"test_weapon_sequence_{custom.GeneratedSequenceName}.dmx\" ),\n\t\t\t\"Authored custom clips must use their persisted readable sequence name.\" );\n\t}\n\n\tprivate static void TestPartVisibility( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = ValidDocument();\n\t\tvar idle = document.EnsureClip( WeaponClipRole.Idle );\n\t\tvar part = new WeaponVisibilityPart\n\t\t{\n\t\t\tName = \"Spare Magazine\",\n\t\t\tBoneId = \"weapon_root\",\n\t\t\tBoneName = \"weapon_root\",\n\t\t\tDefaultVisible = false\n\t\t};\n\t\tdocument.Rig.VisibilityParts.Add( part );\n\t\tvar track = idle.EnsureVisibilityTrack( part.Id );\n\t\tvar show = WeaponVisibilityEvaluator.UpsertKey( track, 0.2f, true );\n\t\tWeaponVisibilityEvaluator.UpsertKey( track, 0.8f, false );\n\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.1f )\n\t\t\t\t&& WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f )\n\t\t\t\t&& !WeaponVisibilityEvaluator.Evaluate( part, idle, 0.9f ),\n\t\t\t\"Visibility tracks must evaluate as stepped state changes from the configured default.\" );\n\t\tvar replacement = WeaponVisibilityEvaluator.UpsertKey( track, 0.2f, false );\n\t\tEqual(\n\t\t\treport,\n\t\t\tshow.Id,\n\t\t\treplacement.Id,\n\t\t\t\"Keying visibility twice at one frame must update the existing key.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f ),\n\t\t\t\"A replaced visibility key must take effect immediately.\" );\n\t\treplacement.Visible = true;\n\n\t\tvar spans = WeaponVisibilityEvaluator.BuildSpans( part, idle );\n\t\tEqual( report, 3, spans.Count, \"Visibility export must cover the full clip with deterministic state spans.\" );\n\t\tNear( report, 0, spans[0].StartTime, 0.0001f, \"The first visibility span must begin at clip start.\" );\n\t\tNear( report, idle.Duration, spans[^1].EndTime, 0.0001f, \"The final visibility span must reach clip end.\" );\n\n\t\tvar skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );\n\t\tvar before = DmxWriter.WriteAnimation( document, skeleton, idle );\n\t\tvar graph = AnimGraphWriter.Write( document, \"host.vmdl\" );\n\t\tvar after = DmxWriter.WriteAnimation( document, skeleton, idle );\n\t\tEqual(\n\t\t\treport,\n\t\t\tbefore,\n\t\t\tafter,\n\t\t\t\"Visibility export must be deterministic and must not mutate authored transforms.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tbefore.Contains( \"\\\"DmeFloatLogLayer\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& before.Contains( \"\\\"0.0001\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& before.Contains( \"-8192\", StringComparison.Ordinal ),\n\t\t\t\"Bone visibility must use native sequence scale and off-screen position channels.\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgraph.Contains( WeaponVisibilityEvaluator.VisibleTag( part.Id ) )\n\t\t\t\t&& graph.Contains( WeaponVisibilityEvaluator.HiddenTag( part.Id ) ),\n\t\t\t\"Generated AnimGraphs must declare both visibility states for every part.\" );\n\n\t\tvar prefab = PrefabWriter.Write( document, \"host.vmdl\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!prefab.Contains( \"WeaponPartVisibilityController\", StringComparison.Ordinal )\n\t\t\t\t&& !prefab.Contains( \"\\\"Name\\\": \\\"source_weapon\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& prefab.Contains( \"\\\"Model\\\": \\\"host.vmdl\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& prefab.Contains( \"\\\"GameLayer\\\": true\", StringComparison.Ordinal )\n\t\t\t\t&& Count( prefab, \"\\\"__type\\\": \\\"Sandbox.SkinnedModelRenderer\\\"\" ) == 2\n\t\t\t\t&& prefab.Contains( \"\\\"Name\\\": \\\"muzzle\\\"\", StringComparison.Ordinal )\n\t\t\t\t&& prefab.Contains( \"\\\"Name\\\": \\\"eject\\\"\", StringComparison.Ordinal ),\n\t\t\t\"Generated prefabs must use one visible host renderer plus bone-merged arms and explicit output anchors, with no custom controller.\" );\n\t\tdocument.Output.GenerateGraph = false;\n\t\tvar graphFreePrefab = PrefabWriter.Write( document, \"host.vmdl\" );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgraphFreePrefab.Contains( \"\\\"UseAnimGraph\\\": false\", StringComparison.Ordinal )\n\t\t\t\t&& !graphFreePrefab.Contains( \"WeaponPartVisibilityController\", StringComparison.Ordinal ),\n\t\t\t\"Graph-free prefabs must remain standard and disable AnimGraph playback.\" );\n\t\tdocument.Output.GenerateGraph = true;\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tcontroller.SetTimelineTime( 0.2f );\n\t\tcontroller.SelectKeys( [show.Id], false );\n\t\tcontroller.CopySelectedKeys();\n\t\tcontroller.SetTimelineTime( 0.5f );\n\t\tcontroller.PasteKeys();\n\t\tCheck(\n\t\t\treport,\n\t\t\tidle.VisibilityTracks.Single( x => x.PartId == part.Id )\n\t\t\t\t.Keys.Any( x => MathF.Abs( x.Time - 0.5f ) <= 0.0001f ),\n\t\t\t\"Visibility keys must participate in the shared copy and paste workflow.\" );\n\n\t\tpart.RenderMode = VisibilityRenderMode.BodyGroup;\n\t\tpart.BodyGroupName = \"\";\n\t\tvar invalid = WeaponAnimationValidator.ValidateForGeneration( document );\n\t\tCheck(\n\t\t\treport,\n\t\t\tinvalid.Issues.Any( x => x.Code == \"visibility.bodygroup_missing\" )\n\t\t\t\t&& invalid.Issues.Any( x => x.Code == \"visibility.bodygroup_export\" ),\n\t\t\t\"Generation validation must reject bodygroup visibility until it can be baked into a standard prefab.\" );\n\t}\n\n\tprivate static WeaponAnimationDocument ValidDocument()\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \"Test Weapon\" );\n\t\tdocument.Source.SourcePath = \"weapons/test/source.fbx\";\n\t\tdocument.Source.CompiledModelPath = \"weapons/test/source.vmdl\";\n\t\tdocument.Source.Compiled = true;\n\t\tdocument.Source.PreviewHostCompiled = true;\n\t\tdocument.Rig.RootBone = \"weapon_root\";\n\t\tdocument.Rig.Bones.Add( new WeaponBoneDefinition\n\t\t{\n\t\t\tId = \"weapon_root\",\n\t\t\tHierarchyPath = \"weapon_root\",\n\t\t\tName = \"weapon_root\",\n\t\t\tOriginalName = \"weapon_root\",\n\t\t\tClassification = WeaponBoneClassification.WeaponRoot,\n\t\t\tInclusion = WeaponBoneInclusion.Included,\n\t\t\tBindTransform = Transform.Zero,\n\t\t\tBindModelTransform = Transform.Zero,\n\t\t\tBindLocalTransform = Transform.Zero,\n\t\t\tHasSkinInfluence = true\n\t\t} );\n\t\tdocument.Rig.SourceSkeletonRootId = \"weapon_root\";\n\t\tdocument.Rig.WeaponSubtreeRootId = \"weapon_root\";\n\t\tdocument.Rig.ReviewRequired = false;\n\t\tdocument.Rig.FilteredPreviewConfirmed = true;\n\t\tdocument.Calibration.SetAnchor( Anchor( AnchorKind.Grip, new Vector3( 1, 0, 0 ) ) );\n\t\tdocument.Calibration.SetAnchor( Anchor( AnchorKind.RearBore, Vector3.Zero ) );\n\t\tdocument.Calibration.SetAnchor( Anchor( AnchorKind.FrontBore, Vector3.Forward ) );\n\t\tdocument.Calibration.SetAnchor( Anchor( AnchorKind.Muzzle, new Vector3( 12, 0, 1 ) ) );\n\t\tdocument.Calibration.SetAnchor( Anchor( AnchorKind.Eject, new Vector3( 4, -1, 2 ) ) );\n\t\tdocument.Calibration.Confirmed = true;\n\t\tdocument.Calibration.Snapshot = new CalibrationSnapshot();\n\t\tdocument.EnsureClip( WeaponClipRole.Idle ).Readiness = ClipReadiness.Ready;\n\t\treturn document;\n\t}\n\n\tprivate static WeaponAnchor Anchor( AnchorKind kind, Vector3 position ) => new()\n\t{\n\t\tName = kind.ToString(),\n\t\tKind = kind,\n\t\tBoneName = \"weapon_root\",\n\t\tLocalPosition = position\n\t};\n\n\tprivate static WeaponBoneDefinition Definition(\n\t\tstring name,\n\t\tstring parent,\n\t\tWeaponBoneClassification classification,\n\t\tVector3 modelPosition ) =>\n\t\tDefinition( name, parent, classification, new Transform( modelPosition ) );\n\n\tprivate static WeaponBoneDefinition Definition(\n\t\tstring name,\n\t\tstring parent,\n\t\tWeaponBoneClassification classification,\n\t\tTransform modelTransform ) => new()\n\t{\n\t\tName = name,\n\t\tParentName = parent,\n\t\tOriginalName = name,\n\t\tOriginalParentName = parent,\n\t\tClassification = classification,\n\t\tInclusion = WeaponBoneInclusion.Included,\n\t\tBindTransform = modelTransform,\n\t\tBindModelTransform = modelTransform,\n\t\tHasSkinInfluence = true\n\t};\n\n\tprivate static HostBone Bone( string name, string parent, Vector3 position ) => new()\n\t{\n\t\tName = name,\n\t\tParentName = parent,\n\t\tBindModelTransform = new Transform( position )\n\t};\n\n\tprivate static float RotationLength( Rotation value ) =>\n\t\tMathF.Sqrt( value.x * value.x + value.y * value.y + value.z * value.z + value.w * value.w );\n\n\tprivate static int Count( string value, string fragment )\n\t{\n\t\tvar count = 0;\n\t\tvar offset = 0;\n\t\twhile ( (offset = value.IndexOf( fragment, offset, StringComparison.Ordinal )) >= 0 )\n\t\t{\n\t\t\tcount++;\n\t\t\toffset += fragment.Length;\n\t\t}\n\t\treturn count;\n\t}\n\n\tprivate static void Run(\n\t\tWeaponAnimatorSelfTestReport report,\n\t\tstring name,\n\t\tAction<WeaponAnimatorSelfTestReport> test )\n\t{\n\t\ttry\n\t\t{\n\t\t\ttest( report );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\treport.Failures.Add( $\"{name}: threw {ex.GetType().Name}: {ex.Message}\" );\n\t\t}\n\t}\n\n\tprivate static void Check(\n\t\tWeaponAnimatorSelfTestReport report,\n\t\tbool condition,\n\t\tstring message )\n\t{\n\t\tif ( condition )\n\t\t\treport.Passed++;\n\t\telse\n\t\t\treport.Failures.Add( message );\n\t}\n\n\tprivate static void Equal<T>(\n\t\tWeaponAnimatorSelfTestReport report,\n\t\tT expected,\n\t\tT actual,\n\t\tstring message )\n\t{\n\t\tCheck(\n\t\t\treport,\n\t\t\tEqualityComparer<T>.Default.Equals( expected, actual ),\n\t\t\t$\"{message} Expected '{expected}', got '{actual}'.\" );\n\t}\n\n\tprivate static void Near(\n\t\tWeaponAnimatorSelfTestReport report,\n\t\tfloat expected,\n\t\tfloat actual,\n\t\tfloat tolerance,\n\t\tstring message )\n\t{\n\t\tCheck(\n\t\t\treport,\n\t\t\tMathF.Abs( expected - actual ) <= tolerance,\n\t\t\t$\"{message} Expected {expected}, got {actual}.\" );\n\t}\n\n\tprivate static void Near(\n\t\tWeaponAnimatorSelfTestReport report,\n\t\tVector3 expected,\n\t\tVector3 actual,\n\t\tfloat tolerance,\n\t\tstring message )\n\t{\n\t\tCheck(\n\t\t\treport,\n\t\t\texpected.Distance( actual ) <= tolerance,\n\t\t\t$\"{message} Expected {expected}, got {actual}.\" );\n\t}\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/WeaponAnimatorWindow.cs",
            "FileName": "WeaponAnimatorWindow.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Globalization;\nusing System.IO;\nusing System.Linq;\nusing System.Text.RegularExpressions;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\n[EditorForAssetType( \"wepanim\" )]\npublic sealed class WeaponAnimatorWindow : DockWindow, IAssetEditor\n{\n\tprivate readonly WeaponAnimatorController _controller = new();\n\tprivate readonly WeaponSourceImporter _importer = new();\n\tprivate readonly AssetGenerationService _generator = new();\n\tprivate bool _generating;\n\tprivate CancellationTokenSource? _generationCancellation;\n\tprivate bool _closeAfterGenerationStops;\n\tprivate bool _refreshingMaterials;\n\tprivate Asset? _asset;\n\tprivate WeaponAnimationAsset? _resource;\n\tprivate Widget? _root;\n\tprivate WeaponAnimatorToolbar? _toolbar;\n\tprivate WeaponAnimatorViewport? _viewport;\n\tprivate ValidationStatusPanel? _statusPanel;\n\tprivate Splitter? _horizontalSplitter;\n\tprivate Splitter? _verticalSplitter;\n\tprivate Splitter? _animationRightSplitter;\n\tprivate Splitter? _animationOuterSplitter;\n\tprivate Button? _validationButton;\n\tprivate Button? _generateButton;\n\tprivate Button? _playButton;\n\tprivate bool _allowClose;\n\tprivate bool _rebaseOnConfirm;\n\tprivate bool _rebuilding;\n\tprivate WeaponAnimationMigrationResult? _migration;\n\tprivate bool _migrationBackupRequired;\n\tprivate bool _recoveryWritePending;\n\tprivate bool _closing;\n\tprivate int _recoveryRequestVersion;\n\n\tpublic bool CanOpenMultipleAssets => false;\n\tpublic void SelectMember( string memberName ) { }\n\n\tpublic WeaponAnimatorWindow()\n\t{\n\t\tDeleteOnClose = true;\n\t\tWindowTitle = \"S&box Weapon Animator\";\n\t\tTitle = WindowTitle;\n\t\tSize = new Vector2( 1600, 940 );\n\t\tMinimumSize = new Vector2( 1200, 720 );\n\t\tStateCookie = \"SboxWeaponAnimator.Window\";\n\t\tSetWindowIcon( \"animation\" );\n\n\t\t_controller.DocumentChanged += OnDocumentChanged;\n\t\t_controller.DirtyChanged += OnDirtyChanged;\n\t\t_controller.PlaybackChanged += RefreshToolbarState;\n\n\t\tBuildMenuBar();\n\t\tBuildWorkspace();\n\t\tShow();\n\t}\n\n\tpublic void AssetOpen( Asset asset )\n\t{\n\t\t_asset = asset;\n\t\t_resource = asset?.LoadResource<WeaponAnimationAsset>() ?? new WeaponAnimationAsset();\n\t\tvar document = _resource.Document ?? WeaponAnimationDocument.CreateDefault();\n\t\tvar adoptedName = AdoptAssetFileName( document, asset );\n\t\t_migration = MigrateAndRepair( document );\n\t\tvar sourceRecovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(\n\t\t\tdocument,\n\t\t\tout var sourceRecoveryMessage );\n\t\t_migrationBackupRequired = _migration.Changed;\n\t\t_controller.SetDocument( document );\n\t\tif ( _migration.Changed || sourceRecovered || adoptedName )\n\t\t\t_controller.ReplaceWithoutHistory( document, true );\n\n\t\tif ( document.ActiveStage == WeaponAnimatorStage.Animate\n\t\t\t&& document.Source.Compiled )\n\t\t{\n\t\t\tPreviewHostBuilder.Build( document );\n\t\t}\n\n\t\tBuildWorkspace();\n\t\tif ( !OfferRecovery() )\n\t\t\tOfferCachedImportRecovery();\n\t\tif ( sourceRecovered )\n\t\t\t_statusPanel?.SetMessage( sourceRecoveryMessage, ValidationSeverity.Warning );\n\t\telse if ( _migration.Changed )\n\t\t\t_statusPanel?.SetMessage( _migration.Summary, ValidationSeverity.Warning );\n\t\tRefreshTitle();\n\t}\n\n\tprotected override bool OnClose()\n\t{\n\t\tSaveWorkspaceState();\n\t\tif ( _generating )\n\t\t{\n\t\t\t_closeAfterGenerationStops = true;\n\t\t\t_generationCancellation?.Cancel();\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\"Cancelling asset generation before closing\u2026\",\n\t\t\t\tValidationSeverity.Warning );\n\t\t\treturn false;\n\t\t}\n\t\tif ( _allowClose || !_controller.IsDirty )\n\t\t{\n\t\t\tDestroyWorkspace();\n\t\t\treturn true;\n\t\t}\n\n\t\tDialog.AskConfirm(\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tif ( Save() )\n\t\t\t\tCloseAfterPrompt();\n\t\t\t},\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tDialog.AskConfirm(\n\t\t\t\t\t// Discarding closes without saving, but the autosave snapshot is kept so the\n\t\t\t\t\t// work is still recoverable on the next open. Only Save clears it.\n\t\t\t\t\t() => CloseAfterPrompt( clearRecovery: false ),\n\t\t\t\t\t\"Discard all unsaved changes to this Weapon Animation Project?\",\n\t\t\t\t\t\"Discard Changes\",\n\t\t\t\t\t\"Discard\",\n\t\t\t\t\t\"Cancel\" );\n\t\t\t},\n\t\t\t\"Save changes before closing this Weapon Animation Project?\",\n\t\t\t\"Unsaved Weapon Animation Project\",\n\t\t\t\"Save\",\n\t\t\t\"More Options\" );\n\t\treturn false;\n\t}\n\n\t[Shortcut( \"editor.save\", \"Ctrl+S\", ShortcutType.Window )]\n\tprivate void ShortcutSave() => Save();\n\n\t[Shortcut( \"editor.undo\", \"Ctrl+Z\", ShortcutType.Window )]\n\tprivate void ShortcutUndo() => _controller.Undo();\n\n\t[Shortcut( \"editor.redo\", \"Ctrl+Y\", ShortcutType.Window )]\n\tprivate void ShortcutRedo() => _controller.Redo();\n\n\t[Shortcut( \"weaponanim.copykeys\", \"Ctrl+C\", ShortcutType.Window )]\n\tprivate void ShortcutCopy() => _controller.CopySelectedKeys();\n\n\t[Shortcut( \"weaponanim.pastekeys\", \"Ctrl+V\", ShortcutType.Window )]\n\tprivate void ShortcutPaste() => _controller.PasteKeys();\n\n\t[Shortcut( \"weaponanim.cutkeys\", \"Ctrl+X\", ShortcutType.Window )]\n\tprivate void ShortcutCut() => _controller.CutSelectedKeys();\n\n\t[Shortcut( \"weaponanim.key\", \"K\", ShortcutType.Window )]\n\tprivate void ShortcutKey() => _controller.KeySelectedTransform();\n\n\t[Shortcut( \"weaponanim.move\", \"W\", ShortcutType.Window )]\n\tprivate void ShortcutMove()\n\t{\n\t\tif ( _viewport?.ConsumesFreeLookMovementShortcut == true )\n\t\t\treturn;\n\t\t_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Move );\n\t}\n\n\t[Shortcut( \"weaponanim.rotate\", \"E\", ShortcutType.Window )]\n\tprivate void ShortcutRotate() =>\n\t\t_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Rotate );\n\n\t[Shortcut( \"weaponanim.scale\", \"R\", ShortcutType.Window )]\n\tprivate void ShortcutScale() =>\n\t\t_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Scale );\n\n\t[EditorEvent.Hotload]\n\tpublic void OnHotload()\n\t{\n\t\tHostSkeletonBuilder.ClearCache();\n\t\tSaveWorkspaceState();\n\t\tMenuBar.Clear();\n\t\tBuildMenuBar();\n\t\tvar sourceRecovered = false;\n\t\tvar sourceRecoveryMessage = \"\";\n\t\t_controller.Mutate(\n\t\t\t\"Recover missing source preview\",\n\t\t\tdocument => sourceRecovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(\n\t\t\t\tdocument,\n\t\t\t\tout sourceRecoveryMessage ) );\n\t\tif ( _controller.Document.Source.Compiled )\n\t\t\tPreviewHostBuilder.Build( _controller.Document );\n\t\tBuildWorkspace();\n\t\tif ( sourceRecovered )\n\t\t\t_statusPanel?.SetMessage( sourceRecoveryMessage, ValidationSeverity.Warning );\n\t}\n\n\tprivate void BuildMenuBar()\n\t{\n\t\tvar file = MenuBar.AddMenu( \"File\" );\n\t\tfile.AddOption( \"New\", \"note_add\", WeaponAnimatorLauncher.CreateNew );\n\t\tfile.AddOption( \"Open\u2026\", \"folder_open\", WeaponAnimatorLauncher.OpenExisting );\n\t\tfile.AddSeparator();\n\t\tfile.AddOption( \"Save\", \"save\", () => Save(), \"editor.save\" );\n\t\tfile.AddOption( \"Save As\u2026\", \"save_as\", SaveAs );\n\t\tfile.AddOption( \"Generate Assets\", \"build\", GenerateAssets );\n\t\tfile.AddSeparator();\n\t\tfile.AddOption( \"Close\", \"close\", Close );\n\n\t\tvar edit = MenuBar.AddMenu( \"Edit\" );\n\t\tedit.AddOption( \"Undo\", \"undo\", _controller.Undo, \"editor.undo\" );\n\t\tedit.AddOption( \"Redo\", \"redo\", _controller.Redo, \"editor.redo\" );\n\t\tedit.AddSeparator();\n\t\tedit.AddOption( \"Cut Keys\", \"content_cut\", _controller.CutSelectedKeys );\n\t\tedit.AddOption( \"Copy Keys\", \"content_copy\", _controller.CopySelectedKeys );\n\t\tedit.AddOption( \"Paste Keys\", \"content_paste\", _controller.PasteKeys );\n\t\tedit.AddOption( \"Delete Keys\", \"delete\", _controller.DeleteSelectedKeys );\n\t\tedit.AddSeparator();\n\t\tedit.AddOption( \"Preferences\u2026\", \"tune\", OpenPreferences );\n\n\t\tvar view = MenuBar.AddMenu( \"View\" );\n\t\tview.AddOption( \"Calibrate\", \"straighten\", RequestCalibrationStage );\n\t\tview.AddOption( \"Animate\", \"animation\", () => SwitchStage( WeaponAnimatorStage.Animate ) );\n\t\tview.AddSeparator();\n\t\tvar guides = view.AddOption( \"Toggle Guides\", \"aspect_ratio\", () =>\n\t\t\t_controller.Mutate( \"Viewport guides\", d => d.Workspace.ShowGuides = !d.Workspace.ShowGuides ) );\n\t\tBindCheckedState( guides, () => _controller.Document.Workspace.ShowGuides );\n\t\tvar skeleton = view.AddOption( \"Toggle Skeleton\", \"accessibility_new\", () =>\n\t\t\t_controller.Mutate( \"Skeleton overlay\", d => d.Workspace.ShowSkeleton = !d.Workspace.ShowSkeleton ) );\n\t\tBindCheckedState( skeleton, () => _controller.Document.Workspace.ShowSkeleton );\n\t\tvar xray = view.AddOption( \"X-Ray Skeleton\", \"visibility\", () =>\n\t\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\t\"X-ray skeleton\",\n\t\t\t\tworkspace => workspace.XRaySkeleton = !workspace.XRaySkeleton ) );\n\t\tBindCheckedState( xray, () => _controller.Document.Workspace.XRaySkeleton );\n\t\tvar boneOcclusion = view.AddOption( \"Bone Occlusion\", \"gradient\", () =>\n\t\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\t\"Bone occlusion\",\n\t\t\t\tworkspace => workspace.BoneOcclusionEnabled =\n\t\t\t\t\t!workspace.BoneOcclusionEnabled ) );\n\t\tBindCheckedState(\n\t\t\tboneOcclusion,\n\t\t\t() => _controller.Document.Workspace.BoneOcclusionEnabled );\n\t\tvar ikBones = view.AddOption( \"Show IK Bones\", \"polyline\", () =>\n\t\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\t\"Show IK bones\",\n\t\t\t\tworkspace => workspace.ShowIkBones = !workspace.ShowIkBones ) );\n\t\tBindCheckedState( ikBones, () => _controller.Document.Workspace.ShowIkBones );\n\t\tvar onionSkins = view.AddOption( \"Toggle Onion Skins\", \"filter_none\", () =>\n\t\t\t_controller.Mutate( \"Onion skins\", d => d.Workspace.ShowOnionSkins = !d.Workspace.ShowOnionSkins ) );\n\t\tBindCheckedState(\n\t\t\tonionSkins,\n\t\t\t() => _controller.Document.Workspace.ShowOnionSkins );\n\t\tvar cameraPreview = view.AddOption( \"Viewmodel Camera Preview\", \"videocam\", () =>\n\t\t\t_controller.Mutate(\n\t\t\t\t\"Preview camera\",\n\t\t\t\td => d.Workspace.FirstPersonPreview = !d.Workspace.FirstPersonPreview ) );\n\t\tBindCheckedState(\n\t\t\tcameraPreview,\n\t\t\t() => _controller.Document.Workspace.FirstPersonPreview );\n\t\tview.AddSeparator();\n\t\tview.AddOption( \"Reset Workspace\", \"restart_alt\", ResetWorkspace );\n\n\t\tvar tools = MenuBar.AddMenu( \"Tools\" );\n\t\ttools.AddOption( \"Validate\", \"rule\", Validate );\n\t\ttools.AddOption( \"Rebuild Preview Rig\", \"refresh\", RebuildPreviewHost );\n\t\ttools.AddOption( \"Reimport Source\", \"published_with_changes\", ReimportSource );\n\t\ttools.AddOption( \"Refresh Materials\", \"texture\", RefreshMaterials );\n\t\ttools.AddOption( \"Open Generated Folder\", \"folder\", OpenGeneratedFolder );\n\t}\n\n\tprivate static void BindCheckedState( Option option, Func<bool> fetch )\n\t{\n\t\toption.Checkable = true;\n\t\toption.Checked = fetch();\n\t\toption.FetchCheckedState = fetch;\n\t}\n\n\tprivate void BuildWorkspace()\n\t{\n\t\tif ( _rebuilding )\n\t\t\treturn;\n\t\t_rebuilding = true;\n\t\tSaveWorkspaceState();\n\t\tDestroyWorkspace();\n\n\t\t_root = new Widget( this );\n\t\t_root.SetStyles( \"background-color: rgb(13,15,17); border: none;\" );\n\t\t_root.Layout = Layout.Column();\n\t\t_root.Layout.Margin = 0;\n\t\t_root.Layout.Spacing = 4;\n\n\t\t_toolbar = new WeaponAnimatorToolbar( _root );\n\t\tBuildToolbar();\n\t\t_root.Layout.Add( _toolbar );\n\n\t\t_viewport = new WeaponAnimatorViewport( _controller );\n\t\t_viewport.StatusChanged += ( message ) => _statusPanel?.SetMessage( message );\n\t\t_viewport.LegacyIdleRepaired += () => _migrationBackupRequired = true;\n\n\t\tif ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )\n\t\t\tBuildCalibrationLayout();\n\t\telse\n\t\t\tBuildAnimationLayout();\n\n\t\tCanvas = _root;\n\t\t_rebuilding = false;\n\t\tRefreshToolbarState();\n\t}\n\n\tprivate void BuildToolbar()\n\t{\n\t\tif ( _toolbar is null )\n\t\t\treturn;\n\t\t_toolbar.Clear();\n\t\t_toolbar.AddLeft( \"Save\", \"save\", () => Save() );\n\t\t_generateButton = _toolbar.AddLeft( \"Generate\", \"build\", GenerateAssets, true );\n\t\tif ( _controller.Document.ActiveStage == WeaponAnimatorStage.Animate )\n\t\t{\n\t\t\t_playButton = _toolbar.AddLeft( \"Play\", \"play_arrow\", TogglePlayback );\n\t\t}\n\t\t_toolbar.AddLeft( \"Undo\", \"undo\", _controller.Undo, overflowAtNarrowWidth: true );\n\t\t_toolbar.AddLeft( \"Redo\", \"redo\", _controller.Redo, overflowAtNarrowWidth: true );\n\n\t\tvar calibrate = _toolbar.AddCenter(\n\t\t\t\"1  Calibrate\",\n\t\t\t\"straighten\",\n\t\t\tRequestCalibrationStage,\n\t\t\t_controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate );\n\t\tcalibrate.IsToggle = true;\n\t\tcalibrate.IsChecked = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate;\n\t\tvar animate = _toolbar.AddCenter(\n\t\t\t\"2  Animate\",\n\t\t\t\"animation\",\n\t\t\t() => SwitchStage( WeaponAnimatorStage.Animate ),\n\t\t\t_controller.Document.ActiveStage == WeaponAnimatorStage.Animate );\n\t\tanimate.IsToggle = true;\n\t\tanimate.IsChecked = _controller.Document.ActiveStage == WeaponAnimatorStage.Animate;\n\n\t\t_validationButton = _toolbar.AddRight( \"Validate\", \"rule\", Validate );\n\t\tRefreshGenerationButton();\n\t\t_toolbar.BalanceCenter();\n\t}\n\n\tprivate void BuildCalibrationLayout()\n\t{\n\t\tif ( _root is null || _viewport is null )\n\t\t\treturn;\n\n\t\tvar rigPanel = new RigAuditPanel( _controller );\n\t\trigPanel.ImportRequested += ImportSource;\n\t\trigPanel.RigReviewConfirmed += RebuildPreviewHost;\n\n\t\tvar inspector = new CalibrationInspectorPanel( _controller );\n\t\tinspector.PickRequested += _viewport.SetPickMode;\n\t\tinspector.AutoAlignRequested += AutoAlign;\n\t\tinspector.ConfirmRequested += ConfirmCalibration;\n\t\tinspector.RebuildPreviewRequested += RebuildPreviewHost;\n\t\tinspector.SetModelDimensions( _viewport.ModelDimensions );\n\t\t_viewport.ModelDimensionsChanged += inspector.SetModelDimensions;\n\n\t\t_statusPanel = new ValidationStatusPanel();\n\t\tvar report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );\n\t\t_statusPanel.SetReport( report );\n\n\t\tvar left = new PanelChrome( \"RIG AUDIT\", \"account_tree\", rigPanel );\n\t\tvar center = new PanelChrome( \"3D CALIBRATION\", \"view_in_ar\", _viewport );\n\t\tvar right = new PanelChrome( \"CALIBRATION\", \"tune\", inspector );\n\t\tvar bottom = new PanelChrome( \"VALIDATION + IMPORT\", \"fact_check\", _statusPanel );\n\t\tleft.MinimumSize = new Vector2( 260, 200 );\n\t\tleft.MaximumSize = new Vector2( 520, 10000 );\n\t\tright.MinimumSize = new Vector2( 310, 200 );\n\t\tright.MaximumSize = new Vector2( 560, 10000 );\n\t\tcenter.MinimumSize = new Vector2( 420, 240 );\n\t\tbottom.MinimumSize = new Vector2( 200, 55 );\n\t\tbottom.MaximumSize = new Vector2( 10000, 190 );\n\t\tBuildSplitLayout( left, center, right, bottom, true );\n\t}\n\n\tprivate void BuildAnimationLayout()\n\t{\n\t\tif ( _root is null || _viewport is null )\n\t\t\treturn;\n\n\t\tvar rigBrowser = new RigBrowserPanel( _controller );\n\t\tvar inspector = new SelectedControlInspectorPanel( _controller );\n\t\tvar clips = new ClipRackPanel(\n\t\t\t_controller,\n\t\t\tshowClipHeader: false );\n\t\tvar timeline = new AnimationTimelinePanel( _controller );\n\t\tclips.StatusChanged += ( message, severity ) => _statusPanel?.SetMessage( message, severity );\n\t\tinspector.StatusChanged += ( message, severity ) => _statusPanel?.SetMessage( message, severity );\n\t\t_statusPanel = new ValidationStatusPanel();\n\t\t_statusPanel.SetReport( WeaponAnimationValidator.ValidateForGeneration( _controller.Document ) );\n\n\t\tvar left = new PanelChrome( \"RIG BROWSER\", \"account_tree\", rigBrowser );\n\t\tvar center = new PanelChrome( \"3D ANIMATION\", \"view_in_ar\", _viewport );\n\t\tvar right = new PanelChrome( \"SELECTED CONTROL\", \"tune\", inspector );\n\t\tvar clipRack = new PanelChrome( \"CLIP RACK\", \"video_library\", clips );\n\t\tvar bottom = new PanelChrome( \"DOPE SHEET \u00b7 CURVES \u00b7 TAGS\", \"timeline\", timeline );\n\t\tleft.MinimumSize = new Vector2( 330, 240 );\n\t\tleft.MaximumSize = new Vector2( 540, 10000 );\n\t\tright.MinimumSize = new Vector2( 370, 240 );\n\t\tright.MaximumSize = new Vector2( 600, 10000 );\n\t\tclipRack.MinimumSize = new Vector2( 370, 260 );\n\t\tclipRack.MaximumSize = new Vector2( 600, 10000 );\n\t\tcenter.MinimumSize = new Vector2( 420, 260 );\n\t\tbottom.MinimumSize = new Vector2( 300, 220 );\n\t\tbottom.MaximumSize = new Vector2( 10000, 520 );\n\t\tBuildAnimationSplitLayout( left, center, right, clipRack, bottom );\n\t}\n\n\tprivate void BuildAnimationSplitLayout(\n\t\tWidget left,\n\t\tWidget center,\n\t\tWidget inspector,\n\t\tWidget clips,\n\t\tWidget timeline )\n\t{\n\t\tif ( _root is null )\n\t\t\treturn;\n\n\t\t_verticalSplitter = new Splitter( _root )\n\t\t{\n\t\t\tIsVertical = true,\n\t\t\tOpaqueResize = true,\n\t\t\tHandleWidth = 4\n\t\t};\n\t\t_horizontalSplitter = new Splitter( _root )\n\t\t{\n\t\t\tIsHorizontal = true,\n\t\t\tOpaqueResize = true,\n\t\t\tHandleWidth = 4\n\t\t};\n\t\t_horizontalSplitter.AddWidget( left );\n\t\t_horizontalSplitter.AddWidget( center );\n\t\t_horizontalSplitter.SetStretch( 0, 0 );\n\t\t_horizontalSplitter.SetStretch( 1, 1 );\n\t\t_horizontalSplitter.SetCollapsible( 0, false );\n\t\t_horizontalSplitter.SetCollapsible( 1, false );\n\n\t\t_verticalSplitter.AddWidget( _horizontalSplitter );\n\t\t_verticalSplitter.AddWidget( timeline );\n\t\t_verticalSplitter.SetStretch( 0, 1 );\n\t\t_verticalSplitter.SetStretch( 1, 0 );\n\t\t_verticalSplitter.SetCollapsible( 0, false );\n\t\t_verticalSplitter.SetCollapsible( 1, false );\n\n\t\t_animationRightSplitter = new Splitter( _root )\n\t\t{\n\t\t\tIsVertical = true,\n\t\t\tOpaqueResize = true,\n\t\t\tHandleWidth = 4\n\t\t};\n\t\t_animationRightSplitter.AddWidget( inspector );\n\t\t_animationRightSplitter.AddWidget( clips );\n\t\t_animationRightSplitter.SetStretch( 0, 1 );\n\t\t_animationRightSplitter.SetStretch( 1, 1 );\n\t\t_animationRightSplitter.SetCollapsible( 0, false );\n\t\t_animationRightSplitter.SetCollapsible( 1, false );\n\n\t\t_animationOuterSplitter = new Splitter( _root )\n\t\t{\n\t\t\tIsHorizontal = true,\n\t\t\tOpaqueResize = true,\n\t\t\tHandleWidth = 4\n\t\t};\n\t\t_animationOuterSplitter.AddWidget( _verticalSplitter );\n\t\t_animationOuterSplitter.AddWidget( _animationRightSplitter );\n\t\t_animationOuterSplitter.SetStretch( 0, 1 );\n\t\t_animationOuterSplitter.SetStretch( 1, 0 );\n\t\t_animationOuterSplitter.SetCollapsible( 0, false );\n\t\t_animationOuterSplitter.SetCollapsible( 1, false );\n\t\t_root.Layout.Add( _animationOuterSplitter, 1 );\n\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tif ( !string.IsNullOrWhiteSpace( workspace.AnimationMainSplitterState ) )\n\t\t\t_horizontalSplitter.RestoreState( workspace.AnimationMainSplitterState );\n\t\tif ( !string.IsNullOrWhiteSpace( workspace.AnimationVerticalSplitterState ) )\n\t\t\t_verticalSplitter.RestoreState( workspace.AnimationVerticalSplitterState );\n\t\tif ( !string.IsNullOrWhiteSpace( workspace.AnimationRightSplitterState ) )\n\t\t\t_animationRightSplitter.RestoreState( workspace.AnimationRightSplitterState );\n\t\tif ( !string.IsNullOrWhiteSpace( workspace.AnimationOuterSplitterState ) )\n\t\t\t_animationOuterSplitter.RestoreState( workspace.AnimationOuterSplitterState );\n\t}\n\n\tprivate void BuildSplitLayout(\n\t\tWidget left,\n\t\tWidget center,\n\t\tWidget right,\n\t\tWidget bottom,\n\t\tbool calibration )\n\t{\n\t\tif ( _root is null )\n\t\t\treturn;\n\t\t_horizontalSplitter = new Splitter( _root )\n\t\t{\n\t\t\tIsHorizontal = true,\n\t\t\tOpaqueResize = true,\n\t\t\tHandleWidth = 4\n\t\t};\n\t\t_horizontalSplitter.AddWidget( left );\n\t\t_horizontalSplitter.AddWidget( center );\n\t\t_horizontalSplitter.AddWidget( right );\n\t\t_horizontalSplitter.SetStretch( 0, 0 );\n\t\t_horizontalSplitter.SetStretch( 1, 1 );\n\t\t_horizontalSplitter.SetStretch( 2, 0 );\n\t\t_horizontalSplitter.SetCollapsible( 0, false );\n\t\t_horizontalSplitter.SetCollapsible( 1, false );\n\t\t_horizontalSplitter.SetCollapsible( 2, false );\n\n\t\t_verticalSplitter = new Splitter( _root )\n\t\t{\n\t\t\tIsVertical = true,\n\t\t\tOpaqueResize = true,\n\t\t\tHandleWidth = 4\n\t\t};\n\t\t_verticalSplitter.AddWidget( _horizontalSplitter );\n\t\t_verticalSplitter.AddWidget( bottom );\n\t\t_verticalSplitter.SetStretch( 0, 1 );\n\t\t_verticalSplitter.SetStretch( 1, 0 );\n\t\t_verticalSplitter.SetCollapsible( 0, false );\n\t\t_verticalSplitter.SetCollapsible( 1, false );\n\t\t_root.Layout.Add( _verticalSplitter, 1 );\n\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tvar horizontalState = calibration\n\t\t\t? workspace.CalibrationSplitterState\n\t\t\t: workspace.AnimationSplitterState;\n\t\tvar verticalState = calibration\n\t\t\t? workspace.CalibrationVerticalSplitterState\n\t\t\t: workspace.AnimationVerticalSplitterState;\n\t\tif ( !string.IsNullOrWhiteSpace( horizontalState ) )\n\t\t\t_horizontalSplitter.RestoreState( horizontalState );\n\t\tif ( !string.IsNullOrWhiteSpace( verticalState ) )\n\t\t\t_verticalSplitter.RestoreState( verticalState );\n\t}\n\n\tprivate void SaveWorkspaceState()\n\t{\n\t\tif ( _horizontalSplitter is null || _verticalSplitter is null )\n\t\t\treturn;\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tif ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )\n\t\t{\n\t\t\tworkspace.CalibrationSplitterState = _horizontalSplitter.SaveState();\n\t\t\tworkspace.CalibrationVerticalSplitterState = _verticalSplitter.SaveState();\n\t\t}\n\t\telse\n\t\t{\n\t\t\tworkspace.AnimationMainSplitterState = _horizontalSplitter.SaveState();\n\t\t\tworkspace.AnimationVerticalSplitterState = _verticalSplitter.SaveState();\n\t\t\tif ( _animationRightSplitter is not null )\n\t\t\t\tworkspace.AnimationRightSplitterState = _animationRightSplitter.SaveState();\n\t\t\tif ( _animationOuterSplitter is not null )\n\t\t\t\tworkspace.AnimationOuterSplitterState = _animationOuterSplitter.SaveState();\n\t\t}\n\t}\n\n\tprivate void DestroyWorkspace()\n\t{\n\t\t// Destroy the private scene synchronously before replacing its widget tree.\n\t\t_viewport?.ReleasePreviewScene();\n\t\tif ( _root.IsValid() )\n\t\t\t_root!.Destroy();\n\t\t_root = null;\n\t\t_toolbar = null;\n\t\t_viewport = null;\n\t\t_statusPanel = null;\n\t\t_horizontalSplitter = null;\n\t\t_verticalSplitter = null;\n\t\t_animationRightSplitter = null;\n\t\t_animationOuterSplitter = null;\n\t\t_validationButton = null;\n\t\t_playButton = null;\n\t}\n\n\tprivate void ImportSource()\n\t{\n\t\tvar dialog = new FileDialog( this )\n\t\t{\n\t\t\tTitle = \"Import Rigged Weapon\",\n\t\t\tDirectory = global::Editor.FileSystem.Content.GetFullPath( \"/\" )\n\t\t};\n\t\tdialog.SetModeOpen();\n\t\tdialog.SetFindExistingFile();\n\t\tdialog.SetNameFilter( \"Rigged Models (*.fbx *.smd *.dmx *.vmdl)\" );\n\t\tif ( !dialog.Execute() )\n\t\t\treturn;\n\n\t\tSourceImportResult? import = null;\n\t\tPreviewHostResult? host = null;\n\t\t_controller.Mutate( \"Import source weapon\", document =>\n\t\t{\n\t\t\timport = _importer.Import( document, dialog.SelectedFile );\n\t\t\tif ( import.Success )\n\t\t\t\thost = PreviewHostBuilder.Build( document );\n\t\t} );\n\n\t\t_statusPanel?.SetMessage(\n\t\t\t$\"{import?.Message} {host?.Message}\",\n\t\t\timport?.Success == true && host?.Success == true\n\t\t\t\t? ValidationSeverity.Info\n\t\t\t\t: ValidationSeverity.Error );\n\t\t_viewport?.RebuildPreview();\n\t}\n\n\tprivate void ReimportSource()\n\t{\n\t\tvar source = string.IsNullOrWhiteSpace( _controller.Document.Source.OriginalSourcePath )\n\t\t\t? _controller.Document.Source.SourcePath\n\t\t\t: _controller.Document.Source.OriginalSourcePath;\n\t\tif ( string.IsNullOrWhiteSpace( source ) )\n\t\t{\n\t\t\tImportSource();\n\t\t\treturn;\n\t\t}\n\n\t\tSourceImportResult? result = null;\n\t\t_controller.Mutate( \"Reimport source weapon\", document =>\n\t\t{\n\t\t\tresult = _importer.Import( document, source );\n\t\t\tif ( result.Success )\n\t\t\t\tPreviewHostBuilder.Build( document );\n\t\t} );\n\t\t_statusPanel?.SetMessage(\n\t\t\tresult?.Message ?? \"Reimport failed.\",\n\t\t\tresult?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );\n\t\t_viewport?.RebuildPreview();\n\t}\n\n\tprivate async void RefreshMaterials()\n\t{\n\t\tif ( _refreshingMaterials || _generating )\n\t\t{\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\"Material refresh or generation is already in progress.\",\n\t\t\t\tValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\t_refreshingMaterials = true;\n\t\tLog.Info( \"[Weapon Animator] manual material refresh requested.\" );\n\t\ttry\n\t\t{\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\"Discovering and compiling source materials\u2026\",\n\t\t\t\tValidationSeverity.Info );\n\t\t\tvar result = await RefreshMaterialsCoreAsync( \"Refresh source materials\" );\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\tresult.Message,\n\t\t\t\tresult.Success\n\t\t\t\t\t? ValidationSeverity.Info\n\t\t\t\t\t: ValidationSeverity.Error );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Error( $\"[Weapon Animator] material refresh threw: {ex}\" );\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t$\"Material refresh failed: {ex.Message}\",\n\t\t\t\tValidationSeverity.Error );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\t_refreshingMaterials = false;\n\t\t\tRefreshToolbarState();\n\t\t}\n\t}\n\n\tprivate async System.Threading.Tasks.Task<SourceImportResult> RefreshMaterialsCoreAsync(\n\t\tstring historyDescription )\n\t{\n\t\tvar recovered = false;\n\t\tvar recoveryMessage = \"\";\n\t\t_controller.Mutate(\n\t\t\t\"Recover missing source preview\",\n\t\t\tdocument => recovered = WeaponSourceImporter.TryRecoverMissingPreviewModel(\n\t\t\t\tdocument,\n\t\t\t\tout recoveryMessage ) );\n\t\tif ( recovered )\n\t\t{\n\t\t\t_viewport?.RebuildPreview();\n\t\t\t_statusPanel?.SetMessage( recoveryMessage, ValidationSeverity.Warning );\n\t\t}\n\n\t\tvar documentId = _controller.Document.DocumentId;\n\t\tvar sourceHash = _controller.Document.Source.SourceHash;\n\t\tvar result = await _importer.RefreshMaterialsAsync( _controller.Document );\n\t\tif ( !result.Success )\n\t\t\treturn result;\n\n\t\tif ( _controller.Document.DocumentId != documentId\n\t\t\t|| !string.Equals(\n\t\t\t\t_controller.Document.Source.SourceHash,\n\t\t\t\tsourceHash,\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\treturn new SourceImportResult\n\t\t\t{\n\t\t\t\tSuccess = false,\n\t\t\t\tMessage = \"The open document or source changed while materials were compiling; \"\n\t\t\t\t\t+ \"the candidate preview was not applied.\"\n\t\t\t};\n\t\t}\n\n\t\t_controller.Mutate(\n\t\t\thistoryDescription,\n\t\t\tdocument => WeaponSourceImporter.ApplyMaterialRefresh( document, result ) );\n\t\t_viewport?.RebuildPreview();\n\t\tWeaponSourceImporter.CleanupLegacyMaterialPreview( _controller.Document );\n\t\treturn result;\n\t}\n\n\tprivate void AutoAlign()\n\t{\n\t\tvar document = _controller.Document;\n\t\tvar grip = document.Calibration.GetAnchor( AnchorKind.Grip );\n\t\tvar rear = document.Calibration.GetAnchor( AnchorKind.RearBore );\n\t\tvar front = document.Calibration.GetAnchor( AnchorKind.FrontBore );\n\t\tif ( grip is null || rear is null || front is null )\n\t\t{\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\"Set the primary grip and both optional alignment markers before running Auto-align.\",\n\t\t\t\tValidationSeverity.Error );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( !WeaponAnimationMath.TryCalculateAlignment(\n\t\t\tgrip.LocalPosition,\n\t\t\trear.LocalPosition,\n\t\t\tfront.LocalPosition,\n\t\t\tdocument.Calibration.UpAxis,\n\t\t\tdocument.Calibration.UniformScale,\n\t\t\tnew Vector3( 12, -3, -2 ),\n\t\t\tout var alignment ) )\n\t\t{\n\t\t\t_statusPanel?.SetMessage( \"The selected anchors cannot produce a finite alignment.\", ValidationSeverity.Error );\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.Mutate( \"Auto-align weapon\", d =>\n\t\t{\n\t\t\td.Calibration.PhysicalTransform = alignment.PhysicalTransform;\n\t\t\tvar rearWorld = alignment.PhysicalTransform.PointToWorld( rear.LocalPosition );\n\t\t\tvar correctionWorld = new Vector3( 0, -rearWorld.y, -rearWorld.z );\n\t\t\tvar correctionLocal = alignment.PhysicalTransform.PointToLocal(\n\t\t\t\talignment.PhysicalTransform.Position + correctionWorld );\n\t\t\td.Calibration.FramingTransform = d.Calibration.FramingTransform.WithPosition( correctionLocal );\n\t\t\td.Calibration.Confirmed = false;\n\t\t} );\n\n\t\t_statusPanel?.SetMessage(\n\t\t\talignment.BoreMayBeReversed\n\t\t\t\t? \"Aligned, but the bore points appear reversed. Swap rear and front if the muzzle faces away from +X.\"\n\t\t\t\t: \"Grip placed at the canonical hand origin; bore aligned to +X and projected through the crosshair.\",\n\t\t\talignment.BoreMayBeReversed ? ValidationSeverity.Warning : ValidationSeverity.Info );\n\t}\n\n\tprivate void ConfirmCalibration()\n\t{\n\t\tPreviewHostResult? hostResult = null;\n\t\t_controller.Mutate( \"Build calibrated preview host\", document =>\n\t\t\thostResult = PreviewHostBuilder.Build( document ) );\n\t\tvar report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );\n\t\tif ( !report.IsValid || hostResult?.Success != true )\n\t\t{\n\t\t\t_statusPanel?.SetReport( report, hostResult?.Message ?? \"\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar previous = _controller.Document.Calibration.Snapshot;\n\t\t_controller.Mutate( \"Confirm calibration\", document =>\n\t\t{\n\t\t\tif ( _rebaseOnConfirm && previous is not null )\n\t\t\t\tCalibrationRebaser.RebaseAnimationData( document, previous );\n\n\t\t\tvar calibration = document.Calibration;\n\t\t\tcalibration.Revision++;\n\t\t\tcalibration.Confirmed = true;\n\t\t\tcalibration.Snapshot = new CalibrationSnapshot\n\t\t\t{\n\t\t\t\tRevision = calibration.Revision,\n\t\t\t\tSourceHash = document.Source.SourceHash,\n\t\t\t\tRigHash = document.Rig.ProfileHash,\n\t\t\t\tUniformScale = calibration.UniformScale,\n\t\t\t\tPhysicalTransform = calibration.PhysicalTransform,\n\t\t\t\tFramingTransform = calibration.FramingTransform,\n\t\t\t\tAnchors = Json.Deserialize<System.Collections.Generic.List<WeaponAnchor>>(\n\t\t\t\t\tJson.Serialize( calibration.Anchors ) ) ?? [],\n\t\t\t\tConfirmedUtc = DateTime.UtcNow\n\t\t\t};\n\t\t\tif ( previous is null )\n\t\t\t\tCalibrationBindingSeeder.SeedDefaultPrimaryHand( document );\n\n\t\t\tvar idle = document.EnsureClip( WeaponClipRole.Idle );\n\t\t\tif ( idle.Tracks.Count == 0 || idle.IsBindPoseSeed )\n\t\t\t{\n\t\t\t\tvar skeleton = HostSkeletonBuilder.BuildCached( document );\n\t\t\t\tIdleBindPoseService.SeedFromCurrentBind( document, skeleton );\n\t\t\t}\n\t\t\tidle.Readiness = ClipReadiness.Ready;\n\t\t\tdocument.Workspace.SelectedClipId = idle.Id;\n\t\t\tdocument.ActiveStage = WeaponAnimatorStage.Animate;\n\t\t} );\n\t\t_rebaseOnConfirm = false;\n\t\tBuildWorkspace();\n\t}\n\n\tprivate void RequestCalibrationStage()\n\t{\n\t\tif ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )\n\t\t\treturn;\n\t\tvar hasAnimation = _controller.Document.Clips.Any( x =>\n\t\t\tx.Tracks.Count > 0 && x.Role != WeaponClipRole.Idle );\n\t\tif ( !hasAnimation )\n\t\t{\n\t\t\tSwitchStage( WeaponAnimatorStage.Calibrate );\n\t\t\treturn;\n\t\t}\n\n\t\tDialog.AskConfirm(\n\t\t\t() =>\n\t\t\t{\n\t\t\t\t_rebaseOnConfirm = true;\n\t\t\t\tSwitchStage( WeaponAnimatorStage.Calibrate );\n\t\t\t},\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tDialog.AskConfirm(\n\t\t\t\t\t() =>\n\t\t\t\t\t{\n\t\t\t\t\t\t_controller.Mutate(\n\t\t\t\t\t\t\t\"Discard animation for recalibration\",\n\t\t\t\t\t\t\tCalibrationRebaser.DiscardAnimationData );\n\t\t\t\t\t\t_rebaseOnConfirm = false;\n\t\t\t\t\t\tSwitchStage( WeaponAnimatorStage.Calibrate );\n\t\t\t\t\t},\n\t\t\t\t\t\"Discard all authored animation and binding data before recalibrating?\",\n\t\t\t\t\t\"Discard Animation Data\",\n\t\t\t\t\t\"Discard\",\n\t\t\t\t\t\"Cancel\" );\n\t\t\t},\n\t\t\t\"Rebase bindings, controls, and animation roots onto the new calibration when it is confirmed?\",\n\t\t\t\"Return to Calibration\",\n\t\t\t\"Rebase\",\n\t\t\t\"Other Options\" );\n\t}\n\n\tprivate void SwitchStage( WeaponAnimatorStage stage )\n\t{\n\t\tif ( stage == _controller.Document.ActiveStage )\n\t\t\treturn;\n\t\tif ( stage == WeaponAnimatorStage.Animate )\n\t\t{\n\t\t\tvar report = WeaponAnimationValidator.ValidateCalibration( _controller.Document );\n\t\t\tif ( !_controller.Document.Calibration.Confirmed || !report.IsValid )\n\t\t\t{\n\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\t\"Confirm a valid calibration before entering Animate.\",\n\t\t\t\t\tValidationSeverity.Error );\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tSaveWorkspaceState();\n\t\t_controller.Mutate( $\"Switch to {stage}\", d => d.ActiveStage = stage );\n\t\tBuildWorkspace();\n\t}\n\n\tprivate bool Save()\n\t{\n\t\tif ( _asset is null || _resource is null )\n\t\t{\n\t\t\tSaveAs();\n\t\t\treturn _asset is not null;\n\t\t}\n\n\t\tSaveWorkspaceState();\n\t\tif ( _migrationBackupRequired )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tWeaponAnimationMigration.CreateBackup(\n\t\t\t\t\t_asset.AbsolutePath,\n\t\t\t\t\t_migration?.SourceSchemaVersion ?? 2 );\n\t\t\t}\n\t\t\tcatch ( Exception ex )\n\t\t\t{\n\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\t$\"Migration backup failed; the project was not saved: {ex.Message}\",\n\t\t\t\t\tValidationSeverity.Error );\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\t_resource.Document = _controller.Document;\n\t\tif ( !_asset.SaveToDisk( _resource ) )\n\t\t{\n\t\t\t_statusPanel?.SetMessage( \"The .wepanim asset could not be saved.\", ValidationSeverity.Error );\n\t\t\treturn false;\n\t\t}\n\n\t\t_controller.MarkSaved();\n\t\t_migrationBackupRequired = false;\n\t\tRecoveryService.Clear( _controller.Document.DocumentId );\n\t\t_statusPanel?.SetMessage( $\"Saved {_asset.Path}.\" );\n\t\tRefreshTitle();\n\t\treturn true;\n\t}\n\n\tprivate void SaveAs()\n\t{\n\t\tvar dialog = new FileDialog( this )\n\t\t{\n\t\t\tTitle = \"Save Weapon Animation Project As\",\n\t\t\tDirectory = global::Editor.FileSystem.Content.GetFullPath( \"/\" ),\n\t\t\tDefaultSuffix = \"wepanim\"\n\t\t};\n\t\tdialog.SetModeSave();\n\t\tdialog.SetFindFile();\n\t\tdialog.SetNameFilter( \"Weapon Animation Project (*.wepanim)\" );\n\t\tif ( !dialog.Execute() )\n\t\t\treturn;\n\n\t\tvar path = Path.ChangeExtension( dialog.SelectedFile, \".wepanim\" );\n\t\tvar asset = AssetSystem.CreateResource( \"wepanim\", path );\n\t\tif ( asset is null )\n\t\t{\n\t\t\t_statusPanel?.SetMessage( \"Could not create the new .wepanim asset.\", ValidationSeverity.Error );\n\t\t\treturn;\n\t\t}\n\n\t\t_asset = asset;\n\t\t// Saving under a new filename renames the project, so the generated output follows it.\n\t\tAdoptAssetFileName( _controller.Document, _asset );\n\t\t_resource = new WeaponAnimationAsset { Document = _controller.Document };\n\t\tif ( !_asset.SaveToDisk( _resource ) )\n\t\t{\n\t\t\t_statusPanel?.SetMessage( \"Save As failed.\", ValidationSeverity.Error );\n\t\t\treturn;\n\t\t}\n\t\t_controller.MarkSaved();\n\t\t_migrationBackupRequired = false;\n\t\tRecoveryService.Clear( _controller.Document.DocumentId );\n\t\tRefreshTitle();\n\t}\n\n\tprivate async void GenerateAssets()\n\t{\n\t\t// Compiling waits on the asset system across frames, so keep a second press from\n\t\t// starting a competing run over the same output files.\n\t\tif ( _generating )\n\t\t{\n\t\t\t_generationCancellation?.Cancel();\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\"Cancelling asset generation safely\u2026\",\n\t\t\t\tValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\t_generating = true;\n\t\t_generationCancellation = new CancellationTokenSource();\n\t\tRefreshGenerationButton();\n\t\tLog.Info( \"[Weapon Animator] asset generation requested.\" );\n\t\ttry\n\t\t{\n\t\t\tif ( WeaponMaterialPipeline.RequiresPreviewRefresh( _controller.Document ) )\n\t\t\t{\n\t\t\t\tvar materialImport = await RefreshMaterialsCoreAsync(\n\t\t\t\t\t\"Discover source materials\" );\n\t\t\t\tif ( !materialImport.Success )\n\t\t\t\t{\n\t\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\t\tmaterialImport.Message,\n\t\t\t\t\t\tValidationSeverity.Error );\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t_statusPanel?.SetMessage( \"Generating and compiling assets\u2026\", ValidationSeverity.Info );\n\t\t\tvar result = await _generator.GenerateAsync(\n\t\t\t\t_controller.Document,\n\t\t\t\tprogress =>\n\t\t\t\t{\n\t\t\t\t\tvar count = progress.Total > 0\n\t\t\t\t\t\t? $\" {progress.Completed}/{progress.Total}\"\n\t\t\t\t\t\t: \"\";\n\t\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\t\t$\"{progress.Stage}{count} \u2014 {progress.Detail}\",\n\t\t\t\t\t\tValidationSeverity.Info );\n\t\t\t\t},\n\t\t\t\t_generationCancellation.Token );\n\t\t\tif ( result.Success )\n\t\t\t{\n\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\t$\"Generated and reloaded {result.GeneratedFiles.Count} files in {result.OutputFolder}.\",\n\t\t\t\t\tValidationSeverity.Info );\n\t\t\t\tSave();\n\t\t\t}\n\t\t\telse if ( result.Cancelled )\n\t\t\t{\n\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\t\"Asset generation cancelled; previous owned outputs were restored.\",\n\t\t\t\t\tValidationSeverity.Warning );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tvar message = string.Join(\n\t\t\t\t\t\"  \u00b7  \",\n\t\t\t\t\tresult.Diagnostics.Where( x => x.Severity == ValidationSeverity.Error )\n\t\t\t\t\t\t.Select( x => x.Message )\n\t\t\t\t\t\t.Take( 4 ) );\n\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\tstring.IsNullOrWhiteSpace( message ) ? \"Generation failed validation.\" : message,\n\t\t\t\t\tValidationSeverity.Error );\n\t\t\t}\n\t\t}\n\t\tcatch ( OperationCanceledException )\n\t\t{\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\"Asset generation cancelled before outputs were changed.\",\n\t\t\t\tValidationSeverity.Warning );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Error( $\"[Weapon Animator] generation threw: {ex}\" );\n\t\t\t_statusPanel?.SetMessage( $\"Generation failed: {ex.Message}\", ValidationSeverity.Error );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\t_generating = false;\n\t\t\t_generationCancellation?.Dispose();\n\t\t\t_generationCancellation = null;\n\t\t\tRefreshGenerationButton();\n\t\t\tRefreshToolbarState();\n\t\t\tif ( _closeAfterGenerationStops )\n\t\t\t{\n\t\t\t\t_closeAfterGenerationStops = false;\n\t\t\t\tClose();\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void RefreshGenerationButton()\n\t{\n\t\tif ( _generateButton is null )\n\t\t\treturn;\n\n\t\t_generateButton.Text = _generating ? \"Cancel\" : \"Generate\";\n\t\t_generateButton.Icon = _generating ? \"stop\" : \"build\";\n\t\t_generateButton.Tint = _generating\n\t\t\t? WeaponAnimatorTheme.Coral * 0.58f\n\t\t\t: WeaponAnimatorTheme.Cyan * 0.72f;\n\t\tif ( _generateButton is WeaponAnimatorButton button )\n\t\t\tbutton.FitToContent( true );\n\t\t_toolbar?.BalanceCenter();\n\t}\n\n\tprivate void Validate()\n\t{\n\t\tvar report = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate\n\t\t\t? WeaponAnimationValidator.ValidateCalibration( _controller.Document )\n\t\t\t: WeaponAnimationValidator.ValidateForGeneration( _controller.Document );\n\t\t_statusPanel?.SetReport( report );\n\t\tRefreshToolbarState();\n\t}\n\n\tprivate void RebuildPreviewHost()\n\t{\n\t\tPreviewHostResult? result = null;\n\t\t_controller.Mutate( \"Rebuild preview host\", document =>\n\t\t\tresult = PreviewHostBuilder.Build( document ) );\n\t\t_statusPanel?.SetMessage(\n\t\t\tresult?.Message ?? \"Preview host rebuild failed.\",\n\t\t\tresult?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );\n\t\t_viewport?.RebuildPreview();\n\t}\n\n\tprivate void OpenGeneratedFolder()\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar path = AssetGenerationService.GetOutputFolder( _controller.Document );\n\t\t\tif ( Directory.Exists( path ) )\n\t\t\t\tEditorUtility.OpenFolder( path );\n\t\t\telse\n\t\t\t\t_statusPanel?.SetMessage( \"Generate assets before opening the output folder.\", ValidationSeverity.Warning );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t$\"Could not resolve the output folder: {ex.Message}\",\n\t\t\t\tValidationSeverity.Error );\n\t\t}\n\t}\n\n\tprivate void TogglePlayback()\n\t{\n\t\t_controller.TogglePlayback();\n\t}\n\n\tprivate void ResetWorkspace()\n\t{\n\t\t_controller.Mutate( \"Reset workspace\", document =>\n\t\t{\n\t\t\tvar state = document.Workspace;\n\t\t\tstate.CameraFocus = Vector3.Zero;\n\t\t\tstate.CameraAngles = new Angles( 12, 180, 0 );\n\t\t\tstate.CameraDistance = 48;\n\t\t\tstate.FreeLookCamera = false;\n\t\t\tstate.CameraPosition = Vector3.Zero;\n\t\t\tstate.CameraMoveSpeed = 1;\n\t\t\tstate.FullBrightViewport = false;\n\t\t\tstate.CalibrationSplitterState = \"\";\n\t\t\tstate.CalibrationVerticalSplitterState = \"\";\n\t\t\tstate.AnimationSplitterState = \"\";\n\t\t\tstate.AnimationVerticalSplitterState = \"\";\n\t\t\tstate.AnimationTimelineSplitterState = \"\";\n\t\t\tstate.AnimationRightSplitterState = \"\";\n\t\t\tstate.AnimationMainSplitterState = \"\";\n\t\t\tstate.AnimationOuterSplitterState = \"\";\n\t\t\tstate.TimelineViews.Clear();\n\t\t\tstate.CurveViews.Clear();\n\t\t} );\n\t\tBuildWorkspace();\n\t\t_viewport?.FitCamera();\n\t}\n\n\tprivate void OpenPreferences()\n\t{\n\t\tnew WeaponAnimatorPreferencesWindow( _controller ).Show();\n\t}\n\n\tprivate void OnDocumentChanged()\n\t{\n\t\tif ( _controller.IsDirty )\n\t\t\tQueueRecoveryWrite();\n\t\tRefreshTitle();\n\t\tRefreshToolbarState();\n\t}\n\n\tprivate void OnDirtyChanged()\n\t{\n\t\tif ( _controller.IsDirty )\n\t\t\tQueueRecoveryWrite();\n\t\tRefreshTitle();\n\t}\n\n\tprivate void QueueRecoveryWrite()\n\t{\n\t\tif ( _closing || !_controller.IsDirty )\n\t\t\treturn;\n\n\t\t_recoveryRequestVersion++;\n\t\tif ( _recoveryWritePending )\n\t\t\treturn;\n\n\t\t_recoveryWritePending = true;\n\t\t_ = WriteRecoveryAfterQuietPeriodAsync();\n\t}\n\n\tprivate async Task WriteRecoveryAfterQuietPeriodAsync()\n\t{\n\t\ttry\n\t\t{\n\t\t\twhile ( !_closing && _controller.IsDirty )\n\t\t\t{\n\t\t\t\tvar requestedVersion = _recoveryRequestVersion;\n\t\t\t\tawait Task.Delay( 750 );\n\t\t\t\tif ( requestedVersion != _recoveryRequestVersion )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t// Re-check after the wait. Saving inside the quiet period clears the recovery\n\t\t\t\t// file, and writing it back would make the next open offer to restore a snapshot\n\t\t\t\t// of an already-saved project.\n\t\t\t\tif ( _closing || !_controller.IsDirty )\n\t\t\t\t\treturn;\n\n\t\t\t\t// Serialize on the main thread: the continuation above can resume on a worker,\n\t\t\t\t// and the document may be mutated while it is being written.\n\t\t\t\tawait GameTask.MainThread();\n\t\t\t\tif ( !_closing && _controller.IsDirty )\n\t\t\t\t\tRecoveryService.Write( _controller.Document );\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\t_recoveryWritePending = false;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// The .wepanim filename is the project's identity: generated folders and asset names follow it.\n\t/// This has to run on every open rather than only for new documents, because\n\t/// <c>WeaponAnimationAsset.Document</c> is initialised with <c>CreateDefault()</c> \u2014 it is never\n\t/// null, so an asset created outside the New Project flow always arrives carrying the\n\t/// \"New Weapon\" default and would otherwise generate into <c>weapons/new_weapon</c>.\n\t/// </summary>\n\tinternal static bool AdoptAssetFileName( WeaponAnimationDocument document, Asset? asset ) =>\n\t\tAdoptAssetFileName( document, asset?.Path );\n\n\tinternal static bool AdoptAssetFileName( WeaponAnimationDocument document, string? assetPath )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( assetPath ) )\n\t\t\treturn false;\n\n\t\tvar fileName = Path.GetFileNameWithoutExtension( assetPath.Replace( '\\\\', '/' ) );\n\t\tvar slug = WeaponAnimationDocument.Slugify( fileName );\n\t\tif ( string.IsNullOrWhiteSpace( slug ) )\n\t\t\treturn false;\n\n\t\tvar changed = false;\n\t\tif ( document.Name != fileName )\n\t\t{\n\t\t\tdocument.Name = fileName;\n\t\t\tchanged = true;\n\t\t}\n\n\t\tdocument.Output ??= new OutputSettings();\n\t\tif ( document.Output.AssetName != slug )\n\t\t{\n\t\t\tdocument.Output.AssetName = slug;\n\t\t\tchanged = true;\n\t\t}\n\t\treturn changed;\n\t}\n\n\tprivate void RefreshTitle()\n\t{\n\t\tWindowTitle = ComposeWindowTitle(\n\t\t\t_asset?.Path ?? \"\",\n\t\t\t_controller.Document.Name,\n\t\t\t_controller.IsDirty );\n\t\tTitle = WindowTitle;\n\t}\n\n\tinternal static string ComposeWindowTitle(\n\t\tstring assetPath,\n\t\tstring documentName,\n\t\tbool dirty )\n\t{\n\t\tvar fileName = string.IsNullOrWhiteSpace( assetPath )\n\t\t\t? documentName\n\t\t\t: Path.GetFileName( assetPath.Replace( '\\\\', '/' ) );\n\t\tif ( string.IsNullOrWhiteSpace( fileName ) )\n\t\t\tfileName = \"New Weapon\";\n\t\treturn $\"S&box Weapon Animator \u2014 {fileName}{(dirty ? \" *\" : \"\")}\";\n\t}\n\n\tprivate void RefreshToolbarState()\n\t{\n\t\tif ( _validationButton is null )\n\t\t\treturn;\n\t\tvar report = _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate\n\t\t\t? WeaponAnimationValidator.ValidateCalibration( _controller.Document )\n\t\t\t: WeaponAnimationValidator.ValidateForGeneration( _controller.Document );\n\t\t_validationButton.Text = report.IsValid\n\t\t\t? report.WarningCount > 0 ? $\"{report.WarningCount} warnings\" : \"Valid\"\n\t\t\t: $\"{report.ErrorCount} errors\";\n\t\tif ( _validationButton is WeaponAnimatorButton validationButton )\n\t\t\tvalidationButton.FitToContent( true );\n\t\t_validationButton.Icon = report.IsValid\n\t\t\t? report.WarningCount > 0 ? \"warning\" : \"check_circle\"\n\t\t\t: \"error\";\n\t\t_validationButton.Tint = report.IsValid\n\t\t\t? report.WarningCount > 0 ? WeaponAnimatorTheme.Amber * 0.45f : WeaponAnimatorTheme.Green * 0.45f\n\t\t\t: WeaponAnimatorTheme.Coral * 0.5f;\n\t\tif ( _playButton is not null )\n\t\t{\n\t\t\t_playButton.Text = _controller.IsPlaying ? \"Pause\" : \"Play\";\n\t\t\t_playButton.Icon = _controller.IsPlaying ? \"pause\" : \"play_arrow\";\n\t\t\tif ( _playButton is WeaponAnimatorButton playButton )\n\t\t\t\tplayButton.FitToContent( true );\n\t\t}\n\t\t_toolbar?.BalanceCenter();\n\t}\n\n\tprivate bool OfferRecovery()\n\t{\n\t\tif ( _asset is null )\n\t\t\treturn false;\n\t\tvar writeUtc = File.Exists( _asset.AbsolutePath )\n\t\t\t? File.GetLastWriteTimeUtc( _asset.AbsolutePath )\n\t\t\t: DateTime.MinValue;\n\t\tvar recovery = RecoveryService.ReadNewerThan( _controller.Document.DocumentId, writeUtc );\n\t\tif ( recovery is null )\n\t\t\treturn false;\n\n\t\tDialog.AskConfirm(\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tvar migration = MigrateAndRepair( recovery );\n\t\t\t\tif ( migration.Migrated )\n\t\t\t\t{\n\t\t\t\t\t_migration = migration;\n\t\t\t\t\t_migrationBackupRequired = true;\n\t\t\t\t}\n\t\t\t\tNormalizeRecoveredSource( recovery );\n\t\t\t\tif ( recovery.Source.Compiled )\n\t\t\t\t\tPreviewHostBuilder.Build( recovery );\n\t\t\t\t_controller.ReplaceWithoutHistory( recovery, true );\n\t\t\t\tBuildWorkspace();\n\t\t\t\tvar message = migration.Migrated\n\t\t\t\t\t? $\"Recovered the newer autosave snapshot. {migration.Summary}\"\n\t\t\t\t\t: \"Recovered the newer autosave snapshot.\";\n\t\t\t\t_statusPanel?.SetMessage( message, ValidationSeverity.Warning );\n\t\t\t},\n\t\t\t() => RecoveryService.Clear( _controller.Document.DocumentId ),\n\t\t\t\"A newer recovery snapshot exists for this project. Restore it?\",\n\t\t\t\"Recover Weapon Animation Project\",\n\t\t\t\"Restore\",\n\t\t\t\"Discard Recovery\" );\n\t\treturn true;\n\t}\n\n\tprivate void OfferCachedImportRecovery()\n\t{\n\t\tvar document = _controller.Document;\n\t\tif ( !string.IsNullOrWhiteSpace( document.Source.SourcePath ) )\n\t\t\treturn;\n\n\t\tvar cachedSource = FindCachedSource( document.DocumentId );\n\t\tif ( string.IsNullOrWhiteSpace( cachedSource ) )\n\t\t\treturn;\n\n\t\tDialog.AskConfirm(\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tvar result = _importer.Import( document, cachedSource );\n\t\t\t\tvar host = result.Success ? PreviewHostBuilder.Build( document ) : null;\n\t\t\t\t_controller.ReplaceWithoutHistory( document, true );\n\t\t\t\tBuildWorkspace();\n\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\t$\"{result.Message} {host?.Message}\",\n\t\t\t\t\tresult.Success && host?.Success == true\n\t\t\t\t\t\t? ValidationSeverity.Info\n\t\t\t\t\t\t: ValidationSeverity.Error );\n\t\t\t},\n\t\t\t() => { },\n\t\t\t\"The saved document is empty, but a previous weapon import remains in its private cache. Recover that import?\",\n\t\t\t\"Recover Cached Weapon Import\",\n\t\t\t\"Recover Import\",\n\t\t\t\"Ignore Cache\" );\n\t}\n\n\tprivate static string FindCachedSource( Guid documentId )\n\t{\n\t\tvar cache = WeaponSourceImporter.GetPreviewCacheRoot( documentId );\n\t\tif ( !Directory.Exists( cache ) )\n\t\t\treturn \"\";\n\n\t\tvar wrapper = Directory.EnumerateFiles( cache, \"source_*.vmdl\" )\n\t\t\t.OrderByDescending( File.GetLastWriteTimeUtc )\n\t\t\t.FirstOrDefault();\n\t\tif ( string.IsNullOrWhiteSpace( wrapper ) )\n\t\t\treturn \"\";\n\n\t\tvar match = Regex.Match(\n\t\t\tFile.ReadAllText( wrapper ),\n\t\t\t\"filename\\\\s*=\\\\s*\\\"(?<path>[^\\\"]+\\\\.(?:fbx|smd|dmx|vmdl))\\\"\",\n\t\t\tRegexOptions.IgnoreCase );\n\t\tif ( !match.Success )\n\t\t\treturn \"\";\n\n\t\tvar relative = match.Groups[\"path\"].Value;\n\t\tvar absolute = global::Editor.FileSystem.Content.GetFullPath( relative );\n\t\tif ( File.Exists( absolute ) )\n\t\t\treturn absolute;\n\n\t\tvar directory = Path.GetDirectoryName( absolute );\n\t\tvar filename = Path.GetFileName( absolute );\n\t\tif ( string.IsNullOrWhiteSpace( directory ) || !Directory.Exists( directory ) )\n\t\t\treturn \"\";\n\n\t\treturn Directory.EnumerateFiles( directory )\n\t\t\t.FirstOrDefault( path =>\n\t\t\t\tPath.GetFileName( path ).Equals( filename, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t?? \"\";\n\t}\n\n\tprivate void NormalizeRecoveredSource( WeaponAnimationDocument document )\n\t{\n\t\tif ( !document.Source.Compiled\n\t\t\t|| !document.Source.NeedsModelDocWrapper\n\t\t\t|| string.IsNullOrWhiteSpace( document.Rig.RootBone )\n\t\t\t|| document.Rig.RootBone.Equals( \"weapon_root\", StringComparison.OrdinalIgnoreCase )\n\t\t\t|| !string.IsNullOrWhiteSpace( document.Source.SourceRootBoneName ) )\n\t\t\treturn;\n\n\t\tvar source = string.IsNullOrWhiteSpace( document.Source.OriginalSourcePath )\n\t\t\t? document.Source.SourcePath\n\t\t\t: document.Source.OriginalSourcePath;\n\t\t_importer.Import( document, source );\n\t}\n\n\tprivate void CloseAfterPrompt( bool clearRecovery = true )\n\t{\n\t\t_closing = true;\n\t\t_recoveryRequestVersion++;\n\t\tif ( clearRecovery )\n\t\t\tRecoveryService.Clear( _controller.Document.DocumentId );\n\t\t_allowClose = true;\n\t\tClose();\n\t}\n\n\tprivate static WeaponAnimationMigrationResult MigrateAndRepair(\n\t\tWeaponAnimationDocument document ) =>\n\t\tWeaponAnimationMigration.MigrateAndRepair( document );\n}\n\npublic static class WeaponAnimatorLauncher\n{\n\t[Menu( \"Editor\", \"Tools/Weapon Animator/Open Weapon Animator\", \"animation\", Priority = 0 )]\n\tpublic static void OpenPicker()\n\t{\n\t\tnew WeaponAnimatorPickerWindow().Show();\n\t}\n\n\tpublic static void CreateNew()\n\t{\n\t\tvar dialog = new FileDialog( null )\n\t\t{\n\t\t\tTitle = \"Create Weapon Animation Project\",\n\t\t\tDirectory = global::Editor.FileSystem.Content.GetFullPath( \"/\" ),\n\t\t\tDefaultSuffix = \"wepanim\"\n\t\t};\n\t\tdialog.SetModeSave();\n\t\tdialog.SetFindFile();\n\t\tdialog.SetNameFilter( \"Weapon Animation Project (*.wepanim)\" );\n\t\tif ( !dialog.Execute() )\n\t\t\treturn;\n\n\t\tvar path = Path.ChangeExtension( dialog.SelectedFile, \".wepanim\" );\n\t\tvar asset = AssetSystem.CreateResource( \"wepanim\", path );\n\t\tif ( asset is null )\n\t\t\treturn;\n\t\tvar resource = new WeaponAnimationAsset\n\t\t{\n\t\t\tDocument = WeaponAnimationDocument.CreateDefault( Path.GetFileNameWithoutExtension( path ) )\n\t\t};\n\t\tasset.SaveToDisk( resource );\n\t\tIAssetEditor.OpenInEditor( asset, out _ );\n\t}\n\n\tpublic static void OpenExisting()\n\t{\n\t\tvar dialog = new FileDialog( null )\n\t\t{\n\t\t\tTitle = \"Open Weapon Animation Project\",\n\t\t\tDirectory = global::Editor.FileSystem.Content.GetFullPath( \"/\" )\n\t\t};\n\t\tdialog.SetModeOpen();\n\t\tdialog.SetFindExistingFile();\n\t\tdialog.SetNameFilter( \"Weapon Animation Project (*.wepanim)\" );\n\t\tif ( !dialog.Execute() )\n\t\t\treturn;\n\n\t\tvar asset = AssetSystem.FindByPath( dialog.SelectedFile )\n\t\t\t?? AssetSystem.RegisterFile( dialog.SelectedFile );\n\t\tif ( asset is not null )\n\t\t\tIAssetEditor.OpenInEditor( asset, out _ );\n\t}\n}\n\ninternal sealed class WeaponAnimatorPickerWindow : Window\n{\n\tpublic WeaponAnimatorPickerWindow()\n\t{\n\t\tDeleteOnClose = true;\n\t\tWindowTitle = \"Weapon Animator\";\n\t\tTitle = WindowTitle;\n\t\tSize = new Vector2( 520, 260 );\n\t\tSetWindowIcon( \"animation\" );\n\n\t\tvar root = new Widget( this );\n\t\troot.SetStyles( \"background-color: rgb(13,15,17);\" );\n\t\troot.Layout = Layout.Column();\n\t\troot.Layout.Margin = new Sandbox.UI.Margin( 28 );\n\t\troot.Layout.Spacing = 14;\n\t\tvar title = WeaponAnimatorTheme.Label( \"WEAPON ANIMATOR\", root );\n\t\ttitle.SetStyles(\n\t\t\t\"background-color: transparent; border: none; padding: 0px;\" +\n\t\t\t$\"font-size: 18px; font-weight: 600; letter-spacing: 1.2px; color: {WeaponAnimatorTheme.Text.Hex};\" );\n\t\troot.Layout.Add( title );\n\t\tvar description = WeaponAnimatorTheme.Label(\n\t\t\t\"Open a document-driven import, calibration, binding, and animation workspace. No active scene or selected GameObject is required.\",\n\t\t\troot,\n\t\t\ttrue );\n\t\tdescription.WordWrap = true;\n\t\troot.Layout.Add( description );\n\t\tvar row = RigAuditPanel.Row( root );\n\t\trow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"New project\",\n\t\t\t\"note_add\",\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tClose();\n\t\t\t\tWeaponAnimatorLauncher.CreateNew();\n\t\t\t},\n\t\t\trow,\n\t\t\ttrue ), 1 );\n\t\trow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Open existing\",\n\t\t\t\"folder_open\",\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tClose();\n\t\t\t\tWeaponAnimatorLauncher.OpenExisting();\n\t\t\t},\n\t\t\trow ), 1 );\n\t\troot.Layout.Add( row );\n\t\troot.Layout.AddStretchCell();\n\t\tCanvas = root;\n\t}\n}\n\ninternal sealed class WeaponAnimatorPreferencesWindow : Window\n{\n\tpublic WeaponAnimatorPreferencesWindow( WeaponAnimatorController controller )\n\t{\n\t\tDeleteOnClose = true;\n\t\tWindowTitle = \"Weapon Animator Preferences\";\n\t\tTitle = WindowTitle;\n\t\tSize = new Vector2( 420, 520 );\n\t\tvar root = new Widget( this );\n\t\troot.SetStyles( \"background-color: rgb(13,15,17);\" );\n\t\troot.Layout = Layout.Column();\n\t\troot.Layout.Margin = new Sandbox.UI.Margin( 18 );\n\t\troot.Layout.Spacing = 8;\n\t\troot.Layout.Add( Toggle(\n\t\t\t\"Auto-key transformed controls\",\n\t\t\tcontroller.Document.Workspace.AutoKey,\n\t\t\tvalue => controller.Mutate( \"Auto-key preference\", d => d.Workspace.AutoKey = value ) ) );\n\t\troot.Layout.Add( Toggle(\n\t\t\t\"Use local gizmo space\",\n\t\t\tcontroller.Document.Workspace.LocalGizmos,\n\t\t\tvalue => controller.Mutate( \"Gizmo preference\", d => d.Workspace.LocalGizmos = value ) ) );\n\t\troot.Layout.Add( Toggle(\n\t\t\t\"Snap position\",\n\t\t\tcontroller.Document.Workspace.SnapPosition,\n\t\t\tvalue => controller.Mutate( \"Position snapping\", d => d.Workspace.SnapPosition = value ) ) );\n\t\troot.Layout.Add( Toggle(\n\t\t\t\"Snap rotation\",\n\t\t\tcontroller.Document.Workspace.SnapRotation,\n\t\t\tvalue => controller.Mutate( \"Rotation snapping\", d => d.Workspace.SnapRotation = value ) ) );\n\t\troot.Layout.Add( Number(\n\t\t\t\"Rotation snap angle\",\n\t\t\tcontroller.Document.Workspace.RotationSnapDegrees,\n\t\t\t0.25f,\n\t\t\t180,\n\t\t\tvalue => controller.UpdateWorkspacePreference(\n\t\t\t\t\"Rotation snap angle\",\n\t\t\t\tworkspace => workspace.RotationSnapDegrees = value ) ) );\n\t\troot.Layout.Add( WeaponAnimatorTheme.SectionLabel(\n\t\t\t\"VIEWPORT GRID\",\n\t\t\troot,\n\t\t\ttopMargin: true ) );\n\t\troot.Layout.Add( Number(\n\t\t\t\"Grid opacity\",\n\t\t\tcontroller.Document.Workspace.GridOpacity,\n\t\t\t0,\n\t\t\t0.5f,\n\t\t\tvalue => controller.UpdateWorkspacePreference(\n\t\t\t\t\"Grid opacity\",\n\t\t\t\tworkspace => workspace.GridOpacity = value ) ) );\n\t\troot.Layout.Add( Number(\n\t\t\t\"Grid line weight\",\n\t\t\tcontroller.Document.Workspace.GridLineThickness,\n\t\t\t0.1f,\n\t\t\t2,\n\t\t\tvalue => controller.UpdateWorkspacePreference(\n\t\t\t\t\"Grid line weight\",\n\t\t\t\tworkspace => workspace.GridLineThickness = value ) ) );\n\t\troot.Layout.Add( WeaponAnimatorTheme.SectionLabel(\n\t\t\t\"VIEWPORT LIGHTING\",\n\t\t\troot,\n\t\t\ttopMargin: true ) );\n\t\troot.Layout.Add( Toggle(\n\t\t\t\"Cyan edge light\",\n\t\t\tcontroller.Document.Workspace.RimLightEnabled,\n\t\t\tvalue => controller.UpdateWorkspacePreference(\n\t\t\t\t\"Cyan edge light\",\n\t\t\t\tworkspace => workspace.RimLightEnabled = value ) ) );\n\t\troot.Layout.Add( Number(\n\t\t\t\"Cyan edge brightness\",\n\t\t\tcontroller.Document.Workspace.RimLightIntensity,\n\t\t\t0,\n\t\t\t12,\n\t\t\tvalue => controller.UpdateWorkspacePreference(\n\t\t\t\t\"Cyan edge brightness\",\n\t\t\t\tworkspace => workspace.RimLightIntensity = value ) ) );\n\t\tvar lightingNote = WeaponAnimatorTheme.Label(\n\t\t\t\"The edge light is disabled automatically in Full Bright.\",\n\t\t\troot,\n\t\t\ttrue );\n\t\tlightingNote.WordWrap = true;\n\t\troot.Layout.Add( lightingNote );\n\t\troot.Layout.AddStretchCell();\n\t\troot.Layout.Add( WeaponAnimatorTheme.Button( \"Close\", \"close\", Close, root, true ) );\n\t\tCanvas = root;\n\n\t\tButton Toggle( string text, bool value, Action<bool> changed )\n\t\t{\n\t\t\tvar button = new WeaponAnimatorButton( text, root )\n\t\t\t{\n\t\t\t\tIsToggle = true,\n\t\t\t\tIsChecked = value,\n\t\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t\t};\n\t\t\tbutton.Toggled = () => changed( button.IsChecked );\n\t\t\treturn button;\n\t\t}\n\n\t\tWidget Number(\n\t\t\tstring text,\n\t\t\tfloat value,\n\t\t\tfloat minimum,\n\t\t\tfloat maximum,\n\t\t\tAction<float> changed )\n\t\t{\n\t\t\tvar container = new Widget( root );\n\t\t\tcontainer.Layout = Layout.Column();\n\t\t\tcontainer.Layout.Margin = 0;\n\t\t\tcontainer.Layout.Spacing = 3;\n\t\t\tvar row = RigAuditPanel.Row( container );\n\t\t\trow.Layout.Add( WeaponAnimatorTheme.Label( text, row, true ), 1 );\n\t\t\tvar edit = new LineEdit( row )\n\t\t\t{\n\t\t\t\tText = value.ToString( \"0.##\", CultureInfo.InvariantCulture ),\n\t\t\t\tFixedWidth = 82,\n\t\t\t\tFixedHeight = 27\n\t\t\t};\n\t\t\tedit.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t\tvar slider = new FloatSlider( container )\n\t\t\t{\n\t\t\t\tMinimum = minimum,\n\t\t\t\tMaximum = maximum,\n\t\t\t\tValue = value,\n\t\t\t\tFixedHeight = 18\n\t\t\t};\n\n\t\t\tvoid Apply( float candidate, bool updateEdit )\n\t\t\t{\n\t\t\t\tvar clamped = Math.Clamp( candidate, minimum, maximum );\n\t\t\t\tif ( updateEdit )\n\t\t\t\t\tedit.Text = clamped.ToString( \"0.##\", CultureInfo.InvariantCulture );\n\t\t\t\tslider.Value = clamped;\n\t\t\t\tchanged( clamped );\n\t\t\t}\n\n\t\t\tedit.TextEdited += textValue =>\n\t\t\t{\n\t\t\t\tif ( float.TryParse(\n\t\t\t\t\ttextValue,\n\t\t\t\t\tNumberStyles.Float,\n\t\t\t\t\tCultureInfo.InvariantCulture,\n\t\t\t\t\tout var parsed )\n\t\t\t\t\t&& WeaponAnimationMath.IsFinite( parsed ) )\n\t\t\t\t\tApply( parsed, false );\n\t\t\t};\n\t\t\tedit.EditingFinished += () => Apply( slider.Value, true );\n\t\t\tslider.OnValueEdited = () => Apply( slider.Value, true );\n\t\t\trow.Layout.Add( edit );\n\t\t\tcontainer.Layout.Add( row );\n\t\t\tcontainer.Layout.Add( slider );\n\t\t\treturn container;\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Widgets/AnimationWorkspacePanels.cs",
            "FileName": "AnimationWorkspacePanels.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic sealed class ClipRackPanel : Widget\n{\n\tprivate readonly WeaponAnimatorController _controller;\n\tprivate readonly ScrollArea _clipScroll;\n\tprivate readonly Widget _clipCanvas;\n\tprivate readonly ScrollArea _propertiesScroll;\n\tprivate readonly Widget _propertiesCanvas;\n\tprivate readonly Label _actionHint;\n\tprivate readonly Dictionary<Guid, WeaponAnimatorButton> _clipButtons = [];\n\tprivate readonly Dictionary<Guid, int> _propertyScrollByClip = [];\n\tprivate string _clipListSignature = \"\";\n\tprivate Guid _lastSelectedClipId;\n\n\tpublic event Action<string, ValidationSeverity>? StatusChanged;\n\n\tpublic ClipRackPanel(\n\t\tWeaponAnimatorController controller,\n\t\tWidget? parent = null,\n\t\tbool showClipHeader = true ) : base( parent )\n\t{\n\t\t_controller = controller;\n\t\tLayout = Layout.Column();\n\t\tLayout.Margin = new Sandbox.UI.Margin( 8 );\n\t\tLayout.Spacing = 6;\n\n\t\tif ( showClipHeader )\n\t\t\tLayout.Add( Header( \"CLIP RACK\", this ) );\n\t\t_clipScroll = new ScrollArea( this )\n\t\t{\n\t\t\tMinimumSize = new Vector2( 200, 70 )\n\t\t};\n\t\t_clipCanvas = new Widget( _clipScroll );\n\t\t_clipCanvas.Layout = Layout.Column();\n\t\t_clipCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();\n\t\t_clipCanvas.Layout.Spacing = 2;\n\t\t_clipScroll.Canvas = _clipCanvas;\n\t\tLayout.Add( _clipScroll, 2 );\n\n\t\tvar actions = RigAuditPanel.Row( this );\n\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Start\",\n\t\t\t\"add_circle\",\n\t\t\tStartSelectedFromDefault,\n\t\t\tactions,\n\t\t\ttrue ), 1 );\n\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Duplicate\",\n\t\t\t\"content_copy\",\n\t\t\tShowDuplicateMenu,\n\t\t\tactions ), 1 );\n\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Import\",\n\t\t\t\"input\",\n\t\t\tShowImportMenu,\n\t\t\tactions ), 1 );\n\t\tLayout.Add( actions );\n\n\t\t_actionHint = WeaponAnimatorTheme.Label( \"\", this, true );\n\t\t_actionHint.WordWrap = true;\n\t\tLayout.Add( _actionHint );\n\n\t\t_propertiesScroll = new ScrollArea( this ) { MinimumHeight = 80 };\n\t\t_propertiesCanvas = new Widget( _propertiesScroll );\n\t\t_propertiesCanvas.Layout = Layout.Column();\n\t\t_propertiesCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();\n\t\t_propertiesCanvas.Layout.Spacing = 4;\n\t\t_propertiesScroll.Canvas = _propertiesCanvas;\n\t\tLayout.Add( _propertiesScroll, 1 );\n\n\t\tvar addCustom = WeaponAnimatorTheme.Button(\n\t\t\t\"Add custom clip\",\n\t\t\t\"playlist_add\",\n\t\t\tAddCustomClip,\n\t\t\tthis );\n\t\tLayout.Add( addCustom );\n\n\t\t_controller.DocumentChanged += Rebuild;\n\t\tRebuild();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t_controller.DocumentChanged -= Rebuild;\n\t\t_controller.SelectionChanged -= Rebuild;\n\t\tbase.OnDestroyed();\n\t}\n\n\tprivate void Rebuild()\n\t{\n\t\tvar clipScroll = _clipScroll.VerticalScrollbar.Value;\n\t\tvar selectedClipId = _controller.Document.Workspace.SelectedClipId;\n\t\tvar propertiesScroll = CapturePropertiesScroll( selectedClipId );\n\t\tvar clipSignature = ClipListSignature();\n\t\tif ( _clipListSignature != clipSignature || _clipButtons.Count == 0 )\n\t\t{\n\t\t\t_clipCanvas.Layout.Clear( true );\n\t\t\t_clipButtons.Clear();\n\n\t\t\tAddClipGroup( \"CORE\", [\n\t\t\t\tWeaponClipRole.Idle, WeaponClipRole.Deploy, WeaponClipRole.Fire,\n\t\t\t\tWeaponClipRole.FireDry, WeaponClipRole.Reload, WeaponClipRole.ReloadEmpty,\n\t\t\t\tWeaponClipRole.Holster\n\t\t\t] );\n\t\t\tAddClipGroup( \"PRESENTATION\", [\n\t\t\t\tWeaponClipRole.Inspect, WeaponClipRole.Sprint, WeaponClipRole.Jump,\n\t\t\t\tWeaponClipRole.Lower, WeaponClipRole.Ironsights\n\t\t\t] );\n\t\t\tAddClipGroup( \"INTERACTION\", [\n\t\t\t\tWeaponClipRole.GrabStance, WeaponClipRole.GrabGestureOne,\n\t\t\t\tWeaponClipRole.GrabGestureTwo, WeaponClipRole.GrabGestureThree,\n\t\t\t\tWeaponClipRole.GrabGestureFour\n\t\t\t] );\n\t\t\tAddClipGroup( \"INCREMENTAL\", [\n\t\t\t\tWeaponClipRole.ReloadEnter, WeaponClipRole.FirstShell,\n\t\t\t\tWeaponClipRole.InsertShell, WeaponClipRole.ReloadExit\n\t\t\t] );\n\n\t\t\tvar custom = _controller.Document.Clips\n\t\t\t\t.Where( x => x.Role == WeaponClipRole.Custom )\n\t\t\t\t.ToArray();\n\t\t\tif ( custom.Length > 0 )\n\t\t\t{\n\t\t\t\t_clipCanvas.Layout.Add( Header( \"CUSTOM\", _clipCanvas ) );\n\t\t\t\tforeach ( var clip in custom )\n\t\t\t\t\tAddClipButton( clip );\n\t\t\t}\n\t\t\t_clipCanvas.Layout.AddStretchCell();\n\t\t\t_clipListSignature = ClipListSignature();\n\t\t\t_clipCanvas.UpdateGeometry();\n\t\t\t_clipScroll.VerticalScrollbar.Value = clipScroll;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tRefreshClipButtons();\n\t\t}\n\n\t\t_propertiesCanvas.Layout.Clear( true );\n\n\t\tvar selected = _controller.Document.GetSelectedClip();\n\t\t_actionHint.Text = selected is null\n\t\t\t? \"Select a clip.\"\n\t\t\t: selected.Readiness == ClipReadiness.NotStarted\n\t\t\t\t? \"Not started \u00b7 choose Start, Duplicate, or Import.\"\n\t\t\t\t: $\"{selected.Readiness} \u00b7 {selected.Duration:0.###} s at {selected.SampleRate:0.#} fps\";\n\t\tBuildClipProperties( selected );\n\t\t_propertiesCanvas.UpdateGeometry();\n\t\t_propertiesScroll.VerticalScrollbar.Value = propertiesScroll;\n\t\t_lastSelectedClipId = selectedClipId;\n\t}\n\n\tprivate void AddClipGroup( string name, IEnumerable<WeaponClipRole> roles )\n\t{\n\t\t_clipCanvas.Layout.Add( Header( name, _clipCanvas ) );\n\t\tforeach ( var role in roles )\n\t\t{\n\t\t\tvar clip = _controller.Document.EnsureClip( role );\n\t\t\tAddClipButton( clip );\n\t\t}\n\t}\n\n\tprivate void BuildClipProperties( WeaponAnimationClip? clip )\n\t{\n\t\tif ( _propertiesCanvas is null || clip is null )\n\t\t\treturn;\n\t\t_propertiesCanvas.Layout.Add( Header( \"CLIP PROPERTIES\", _propertiesCanvas ) );\n\t\tif ( clip.Role == WeaponClipRole.Custom )\n\t\t\tAddCustomClipProperties( clip );\n\t\tvar sequence = WeaponAnimatorTheme.Label(\n\t\t\t$\"Sequence: {WeaponAnimationNames.SequenceName( clip )}\",\n\t\t\t_propertiesCanvas,\n\t\t\ttrue );\n\t\tsequence.ToolTip = \"Generated sequence name\";\n\t\t_propertiesCanvas.Layout.Add( sequence );\n\t\tAddClipNumber(\n\t\t\t\"Duration\",\n\t\t\tclip.Duration,\n\t\t\tvalue => _controller.Mutate( \"Clip duration\", _ =>\n\t\t\t{\n\t\t\t\tclip.Duration = MathF.Max( value, 1.0f / clip.SampleRate );\n\t\t\t\tclip.KeysClampToDuration();\n\t\t\t} ) );\n\t\tAddClipNumber(\n\t\t\t\"Sample rate\",\n\t\t\tclip.SampleRate,\n\t\t\tvalue => _controller.Mutate(\n\t\t\t\t\"Clip sample rate\",\n\t\t\t\t_ => clip.SampleRate = Math.Clamp( value, 1, 240 ) ) );\n\t\t_propertiesCanvas.Layout.Add( ClipChoice(\n\t\t\t$\"Readiness: {clip.Readiness}\",\n\t\t\tEnum.GetNames<ClipReadiness>(),\n\t\t\tvalue => _controller.Mutate(\n\t\t\t\t\"Clip readiness\",\n\t\t\t\t_ => clip.Readiness = Enum.Parse<ClipReadiness>( value ) ) ) );\n\t\t_propertiesCanvas.Layout.Add( ClipChoice(\n\t\t\t$\"Interpolation: {DominantInterpolation( clip )}\",\n\t\t\tEnum.GetNames<TrackInterpolation>(),\n\t\t\tvalue => _controller.Mutate( \"Track interpolation\", _ =>\n\t\t\t{\n\t\t\t\tvar interpolation = Enum.Parse<TrackInterpolation>( value );\n\t\t\t\tforeach ( var track in clip.Tracks )\n\t\t\t\t\ttrack.Interpolation = interpolation;\n\t\t\t} ) ) );\n\t\t_propertiesCanvas.Layout.Add( Header( \"TAGS\", _propertiesCanvas ) );\n\t\tvar tagRow = RigAuditPanel.Row( _propertiesCanvas );\n\t\tvar name = new LineEdit( tagRow )\n\t\t{\n\t\t\tPlaceholderText = \"Tag name\",\n\t\t\tFixedHeight = 27\n\t\t};\n\t\tname.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\ttagRow.Layout.Add( name, 1 );\n\t\ttagRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Point\",\n\t\t\t\"add_location\",\n\t\t\t() => AddClipTag( name.Text, AnimationTagKind.Point ),\n\t\t\ttagRow ) );\n\t\ttagRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Range\",\n\t\t\t\"linear_scale\",\n\t\t\t() => AddClipTag( name.Text, AnimationTagKind.Range ),\n\t\t\ttagRow ) );\n\t\t_propertiesCanvas.Layout.Add( tagRow );\n\t\tforeach ( var tag in clip.Tags )\n\t\t{\n\t\t\t_propertiesCanvas.Layout.Add( WeaponAnimatorTheme.Label(\n\t\t\t\t$\"{tag.Name}  {tag.StartTime:0.###}\u2013{tag.EndTime:0.###}\",\n\t\t\t\t_propertiesCanvas,\n\t\t\t\ttrue ) );\n\t\t}\n\t\t_propertiesCanvas.Layout.AddStretchCell();\n\t}\n\n\tprivate void AddCustomClipProperties( WeaponAnimationClip clip )\n\t{\n\t\tif ( _propertiesCanvas is null )\n\t\t\treturn;\n\n\t\tvar nameRow = RigAuditPanel.Row( _propertiesCanvas );\n\t\tnameRow.Layout.Add( WeaponAnimatorTheme.Label( \"Name\", nameRow, true ) );\n\t\tvar name = new LineEdit( nameRow )\n\t\t{\n\t\t\tText = clip.Name,\n\t\t\tFixedHeight = 26\n\t\t};\n\t\tname.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\tname.EditingFinished += () =>\n\t\t{\n\t\t\tvar renamed = name.Text.Trim();\n\t\t\tif ( string.IsNullOrWhiteSpace( renamed ) )\n\t\t\t{\n\t\t\t\tname.Text = clip.Name;\n\t\t\t\tStatusChanged?.Invoke(\n\t\t\t\t\t\"A custom clip name cannot be empty.\",\n\t\t\t\t\tValidationSeverity.Warning );\n\t\t\t\treturn;\n\t\t\t}\n\t\t\t_controller.RenameCustomClip( clip.Id, renamed );\n\t\t};\n\t\tnameRow.Layout.Add( name, 1 );\n\t\t_propertiesCanvas.Layout.Add( nameRow );\n\n\t\tvar delete = (WeaponAnimatorButton)WeaponAnimatorTheme.Button(\n\t\t\t\"Delete custom clip\",\n\t\t\t\"delete\",\n\t\t\t() => RequestDeleteCustomClip( clip ),\n\t\t\t_propertiesCanvas );\n\t\tdelete.Tint = WeaponAnimatorTheme.Coral * 0.38f;\n\t\t_propertiesCanvas.Layout.Add( delete );\n\t}\n\n\tprivate void RequestDeleteCustomClip( WeaponAnimationClip clip )\n\t{\n\t\tDialog.AskConfirm(\n\t\t\t() => _controller.DeleteCustomClip( clip.Id ),\n\t\t\t$\"Delete the custom clip '{clip.Name}' and all of its keys, curves, tags, and visibility tracks?\",\n\t\t\t\"Delete Custom Clip\",\n\t\t\t\"Delete\",\n\t\t\t\"Cancel\" );\n\t}\n\n\tprivate void AddClipNumber( string label, float value, Action<float> changed )\n\t{\n\t\tif ( _propertiesCanvas is null )\n\t\t\treturn;\n\t\tvar row = RigAuditPanel.Row( _propertiesCanvas );\n\t\trow.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );\n\t\tvar edit = new LineEdit( row )\n\t\t{\n\t\t\tText = value.ToString( \"0.###\", CultureInfo.InvariantCulture ),\n\t\t\tFixedWidth = 84,\n\t\t\tFixedHeight = 26\n\t\t};\n\t\tedit.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\tedit.EditingFinished += () =>\n\t\t{\n\t\t\tif ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed )\n\t\t\t\t&& WeaponAnimationMath.IsFinite( parsed ) )\n\t\t\t\tchanged( parsed );\n\t\t};\n\t\trow.Layout.Add( edit );\n\t\t_propertiesCanvas.Layout.Add( row );\n\t}\n\n\tprivate Button ClipChoice(\n\t\tstring text,\n\t\tIEnumerable<string> values,\n\t\tAction<string> changed )\n\t{\n\t\tvar button = new WeaponAnimatorButton( text, \"expand_more\", _propertiesCanvas )\n\t\t{\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Clicked = () =>\n\t\t{\n\t\t\tvar menu = new Menu( button );\n\t\t\tforeach ( var value in values )\n\t\t\t{\n\t\t\t\tvar captured = value;\n\t\t\t\tmenu.AddOption( captured, null, () => changed( captured ) );\n\t\t\t}\n\t\t\tmenu.OpenAt( button.ScreenRect.BottomLeft );\n\t\t};\n\t\treturn button;\n\t}\n\n\tprivate void AddClipTag( string name, AnimationTagKind kind )\n\t{\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tif ( clip is null || string.IsNullOrWhiteSpace( name ) )\n\t\t\treturn;\n\t\t_controller.Mutate( $\"Add tag {name}\", document =>\n\t\t{\n\t\t\tvar start = document.Workspace.TimelineTime;\n\t\t\tclip.Tags.Add( new AnimationTag\n\t\t\t{\n\t\t\t\tName = name.Trim(),\n\t\t\t\tKind = kind,\n\t\t\t\tStartTime = start,\n\t\t\t\tEndTime = kind == AnimationTagKind.Range\n\t\t\t\t\t? MathF.Min( start + 0.1f, clip.Duration )\n\t\t\t\t\t: start\n\t\t\t} );\n\t\t} );\n\t}\n\n\tprivate static TrackInterpolation DominantInterpolation( WeaponAnimationClip clip ) =>\n\t\tclip.Tracks.GroupBy( x => x.Interpolation )\n\t\t\t.OrderByDescending( x => x.Count() )\n\t\t\t.Select( x => x.Key )\n\t\t\t.FirstOrDefault();\n\n\tprivate void AddClipButton( WeaponAnimationClip clip )\n\t{\n\t\tvar button = new WeaponAnimatorButton( \"\", _clipCanvas )\n\t\t{\n\t\t\tClicked = () => _controller.SelectClip( clip.Id )\n\t\t};\n\t\tApplyClipButtonAppearance( button, clip );\n\t\t_clipCanvas.Layout.Add( button );\n\t\t_clipButtons[clip.Id] = button;\n\t}\n\n\tprivate void StartSelectedFromDefault()\n\t{\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tif ( clip is null )\n\t\t\treturn;\n\n\t\t_controller.Mutate( $\"Start {clip.Name}\", document =>\n\t\t{\n\t\t\tdocument.Workspace.ClearWorkingPoses( clip.Id );\n\t\t\tdocument.Workspace.TimelineViews.RemoveAll( x => x.ClipId == clip.Id );\n\t\t\tdocument.Workspace.CurveViews.RemoveAll( x => x.ClipId == clip.Id );\n\t\t\tclip.VisibilityTracks.Clear();\n\t\t\tvar skeleton = HostSkeletonBuilder.BuildCached( document );\n\t\t\tif ( clip.Role == WeaponClipRole.Idle )\n\t\t\t{\n\t\t\t\tIdleBindPoseService.SeedFromCurrentBind( document, skeleton );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tclip.Tracks.Clear();\n\t\t\tclip.IsBindPoseSeed = false;\n\t\t\tforeach ( var bone in skeleton.Bones )\n\t\t\t{\n\t\t\t\tvar track = clip.EnsureTrack( bone.Name );\n\t\t\t\ttrack.Kind = bone.IsWeaponBone ? RigControlKind.Weapon : RigControlKind.Arm;\n\t\t\t\tvar gripTransform = document.Binding.GripPoses\n\t\t\t\t\t.FirstOrDefault( x => x.Id == document.Binding.DefaultGripPoseId )?\n\t\t\t\t\t.Bones.FirstOrDefault( x => x.BoneName.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) )?\n\t\t\t\t\t.LocalTransform;\n\t\t\t\tWeaponAnimationMath.UpsertKey(\n\t\t\t\t\ttrack,\n\t\t\t\t\t0,\n\t\t\t\t\tgripTransform ?? skeleton.GetBindLocal( bone ) );\n\t\t\t}\n\t\t\tclip.Readiness = clip.Role == WeaponClipRole.Idle\n\t\t\t\t? ClipReadiness.Ready\n\t\t\t\t: ClipReadiness.Draft;\n\t\t} );\n\t}\n\n\tprivate void ShowDuplicateMenu()\n\t{\n\t\tvar selected = _controller.Document.GetSelectedClip();\n\t\tif ( selected is null )\n\t\t\treturn;\n\t\tvar menu = new Menu( this );\n\t\tforeach ( var source in _controller.Document.Clips.Where( x =>\n\t\t\tx.Id != selected.Id && x.Readiness != ClipReadiness.NotStarted ) )\n\t\t{\n\t\t\tvar captured = source;\n\t\t\tmenu.AddOption( captured.Name, null, () => Duplicate( captured, selected ) );\n\t\t}\n\t\tmenu.OpenAtCursor();\n\t}\n\n\tprivate void Duplicate( WeaponAnimationClip source, WeaponAnimationClip destination )\n\t{\n\t\t_controller.Mutate( $\"Duplicate {source.Name}\", _ =>\n\t\t{\n\t\t\t_controller.Document.Workspace.ClearWorkingPoses( destination.Id );\n\t\t\t_controller.Document.Workspace.TimelineViews.RemoveAll( x =>\n\t\t\t\tx.ClipId == destination.Id );\n\t\t\t_controller.Document.Workspace.CurveViews.RemoveAll( x =>\n\t\t\t\tx.ClipId == destination.Id );\n\t\t\tvar copy = Json.Deserialize<WeaponAnimationClip>( Json.Serialize( source ) )!;\n\t\t\tdestination.Duration = copy.Duration;\n\t\t\tdestination.SampleRate = copy.SampleRate;\n\t\t\tdestination.AllowSubframeKeys = copy.AllowSubframeKeys;\n\t\t\tdestination.IsBindPoseSeed = false;\n\t\t\tdestination.Tracks = copy.Tracks;\n\t\t\tdestination.VisibilityTracks = copy.VisibilityTracks;\n\t\t\tdestination.Constraints = copy.Constraints;\n\t\t\tdestination.Tags = copy.Tags;\n\t\t\tdestination.Readiness = ClipReadiness.Draft;\n\t\t} );\n\t}\n\n\tprivate void ShowImportMenu()\n\t{\n\t\tvar selected = _controller.Document.GetSelectedClip();\n\t\tif ( selected is null )\n\t\t\treturn;\n\t\tvar sequences = SequenceImportService.GetSequences( _controller.Document );\n\t\tif ( sequences.Count == 0 )\n\t\t{\n\t\t\tStatusChanged?.Invoke( \"The source model exposes no importable sequences.\", ValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\tvar menu = new Menu( this );\n\t\tforeach ( var sequence in sequences )\n\t\t{\n\t\t\tvar captured = sequence;\n\t\t\tmenu.AddOption( captured, null, () =>\n\t\t\t{\n\t\t\t\tSequenceImportResult? result = null;\n\t\t\t\t_controller.Mutate( $\"Import {captured}\", document =>\n\t\t\t\t{\n\t\t\t\t\tdocument.Workspace.ClearWorkingPoses( selected.Id );\n\t\t\t\t\tdocument.Workspace.TimelineViews.RemoveAll( x =>\n\t\t\t\t\t\tx.ClipId == selected.Id );\n\t\t\t\t\tdocument.Workspace.CurveViews.RemoveAll( x =>\n\t\t\t\t\t\tx.ClipId == selected.Id );\n\t\t\t\t\tselected.IsBindPoseSeed = false;\n\t\t\t\t\tresult = SequenceImportService.Import( document, selected, captured );\n\t\t\t\t} );\n\t\t\t\tStatusChanged?.Invoke(\n\t\t\t\t\tresult?.Message ?? \"Sequence import failed.\",\n\t\t\t\t\tresult?.Success == true ? ValidationSeverity.Info : ValidationSeverity.Error );\n\t\t\t} );\n\t\t}\n\t\tmenu.OpenAtCursor();\n\t}\n\n\tprivate void AddCustomClip()\n\t{\n\t\t_controller.Mutate( \"Add custom clip\", document =>\n\t\t{\n\t\t\tvar count = document.Clips.Count( x => x.Role == WeaponClipRole.Custom ) + 1;\n\t\t\tvar clip = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\t\tclip.Name = $\"Custom {count}\";\n\t\t\tdocument.Clips.Add( clip );\n\t\t\tWeaponAnimationNames.RepairCustomSequenceNames( document );\n\t\t\tdocument.Workspace.SelectedClipId = clip.Id;\n\t\t} );\n\t}\n\n\tprivate static Label Header( string text, Widget parent )\n\t{\n\t\tvar label = WeaponAnimatorTheme.SectionLabel( text, parent );\n\t\tlabel.FixedHeight = 22;\n\t\tlabel.SetStyles(\n\t\t\t\"background-color: transparent; border: none; padding: 5px 0 0 0;\" +\n\t\t\t$\"font-size: 9px; font-weight: 600; letter-spacing: 0.65px; color: {WeaponAnimatorTheme.Muted.Hex};\" );\n\t\treturn label;\n\t}\n\n\tprivate int CapturePropertiesScroll( Guid selectedClipId )\n\t{\n\t\tif ( _propertiesScroll is null )\n\t\t\treturn 0;\n\n\t\tif ( _lastSelectedClipId != Guid.Empty )\n\t\t\t_propertyScrollByClip[_lastSelectedClipId] =\n\t\t\t\t_propertiesScroll.VerticalScrollbar.Value;\n\t\treturn _lastSelectedClipId == selectedClipId\n\t\t\t? _propertiesScroll.VerticalScrollbar.Value\n\t\t\t: _propertyScrollByClip.GetValueOrDefault( selectedClipId );\n\t}\n\n\tprivate string ClipListSignature() => string.Join(\n\t\t\"|\",\n\t\t_controller.Document.Clips.Select( x =>\n\t\t\t$\"{x.Id}:{x.Role}:{x.Name}:{x.Readiness}\" ) );\n\n\tprivate void RefreshClipButtons()\n\t{\n\t\tforeach ( var clip in _controller.Document.Clips )\n\t\t{\n\t\t\tif ( !_clipButtons.TryGetValue( clip.Id, out var button ) )\n\t\t\t\tcontinue;\n\t\t\tApplyClipButtonAppearance( button, clip );\n\t\t}\n\t}\n\n\tprivate void ApplyClipButtonAppearance(\n\t\tWeaponAnimatorButton button,\n\t\tWeaponAnimationClip clip )\n\t{\n\t\tvar marker = clip.Readiness switch\n\t\t{\n\t\t\tClipReadiness.NotStarted => \"\u25cb\",\n\t\t\tClipReadiness.Draft => \"\u25d0\",\n\t\t\tClipReadiness.Ready => \"\u25cf\",\n\t\t\t_ => \"!\"\n\t\t};\n\t\tbutton.Text = $\"{marker}  {clip.Name}\";\n\t\tbutton.Tint = clip.Id == _controller.Document.Workspace.SelectedClipId\n\t\t\t? WeaponAnimatorTheme.Cyan * 0.42f\n\t\t\t: clip.Readiness switch\n\t\t\t{\n\t\t\t\tClipReadiness.Ready => WeaponAnimatorTheme.Green * 0.24f,\n\t\t\t\tClipReadiness.Warning => WeaponAnimatorTheme.Coral * 0.28f,\n\t\t\t\t_ => WeaponAnimatorTheme.Surface\n\t\t\t};\n\t\tbutton.ToolTip = clip.Readiness.ToString();\n\t}\n\n\tinternal ScrollArea ClipScroll => _clipScroll;\n\tinternal ScrollArea? PropertiesScroll => _propertiesScroll;\n\tinternal WeaponAnimatorButton? GetClipButton( Guid clipId ) =>\n\t\t_clipButtons.GetValueOrDefault( clipId );\n}\n\npublic sealed class AnimationInspectorPanel : Widget\n{\n\tprivate readonly WeaponAnimatorController _controller;\n\tprivate readonly Widget _canvas;\n\tprivate readonly bool _controlToolsOnly;\n\tprivate readonly Dictionary<string, bool> _expandedSections = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t[\"binding\"] = true,\n\t\t[\"constraints\"] = true,\n\t\t[\"animgraph\"] = false\n\t};\n\tpublic event Action<string, ValidationSeverity>? StatusChanged;\n\n\tpublic AnimationInspectorPanel(\n\t\tWeaponAnimatorController controller,\n\t\tWidget? parent = null,\n\t\tbool controlToolsOnly = false ) : base( parent )\n\t{\n\t\t_controller = controller;\n\t\t_controlToolsOnly = controlToolsOnly;\n\t\tLayout = Layout.Column();\n\t\tLayout.Margin = 0;\n\t\tvar scroll = new ScrollArea( this );\n\t\t_canvas = new Widget( scroll );\n\t\t_canvas.Layout = Layout.Column();\n\t\t_canvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin( 10 );\n\t\t_canvas.Layout.Spacing = 7;\n\t\tscroll.Canvas = _canvas;\n\t\tLayout.Add( scroll, 1 );\n\n\t\t_controller.DocumentChanged += Rebuild;\n\t\t_controller.SelectionChanged += Rebuild;\n\t\tRebuild();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t_controller.DocumentChanged -= Rebuild;\n\t\t_controller.SelectionChanged -= Rebuild;\n\t\tbase.OnDestroyed();\n\t}\n\n\tprivate void Rebuild()\n\t{\n\t\t_canvas?.Layout.Clear( true );\n\t\tif ( _canvas is null )\n\t\t\treturn;\n\n\t\tif ( !_controlToolsOnly )\n\t\t{\n\t\t\t_canvas.Layout.Add( Header( \"CONTROL INSPECTOR\" ) );\n\t\t\t_canvas.Layout.Add( WeaponAnimatorTheme.Label( SelectionName(), _canvas ) );\n\t\t}\n\n\t\tvar bindingCanvas = _controlToolsOnly\n\t\t\t? AddCollapsibleSection( \"BINDING + GRIP POSES\", \"binding\" )\n\t\t\t: _canvas;\n\t\tvar selectedControl = _controller.Document.Workspace.SelectedControl;\n\t\tif ( !string.IsNullOrWhiteSpace( selectedControl ) )\n\t\t{\n\t\t\tvar selectedTarget = ResolveControl( selectedControl );\n\t\t\tif ( selectedTarget is not null\n\t\t\t\t&& selectedControl is \"@primary_hand\" or \"@support_hand\" )\n\t\t\t{\n\t\t\t\tvar instruction = WeaponAnimatorTheme.Label(\n\t\t\t\t\t\"Keep this hand selected. Choose its attachment bone from the menu below; \"\n\t\t\t\t\t+ \"you do not need to select the weapon bone in the rig browser.\",\n\t\t\t\t\tbindingCanvas,\n\t\t\t\t\ttrue );\n\t\t\t\tinstruction.WordWrap = true;\n\t\t\t\tbindingCanvas.Layout.Add( instruction );\n\n\t\t\t\tvar weaponBones = HostSkeletonBuilder.BuildCached( _controller.Document )\n\t\t\t\t\t.Bones\n\t\t\t\t\t.Where( x => x.IsWeaponBone )\n\t\t\t\t\t.Select( x => x.Name )\n\t\t\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t\t\t.ToList();\n\t\t\t\tbindingCanvas.Layout.Add( ChoiceButton(\n\t\t\t\t\t\"Attachment bone\",\n\t\t\t\t\t() => string.IsNullOrWhiteSpace( selectedTarget.AttachedBone )\n\t\t\t\t\t\t? \"weapon_root (recommended on bind)\"\n\t\t\t\t\t\t: selectedTarget.AttachedBone,\n\t\t\t\t\tweaponBones.Prepend( \"(world)\" ),\n\t\t\t\t\tvalue => _controller.Mutate( \"Change hand attachment\", document =>\n\t\t\t\t\t{\n\t\t\t\t\t\tHandAttachmentService.ChangeAttachment(\n\t\t\t\t\t\t\tdocument,\n\t\t\t\t\t\t\tselectedControl,\n\t\t\t\t\t\t\tvalue == \"(world)\" ? \"\" : value );\n\t\t\t\t\t} ),\n\t\t\t\t\tbindingCanvas ) );\n\n\t\t\t\tbindingCanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\t\tselectedTarget.IsBound ? $\"Unbind {selectedTarget.Name}\" : $\"Bind {selectedTarget.Name}\",\n\t\t\t\t\tselectedTarget.IsBound ? \"link_off\" : \"link\",\n\t\t\t\t\t() => ToggleHandBinding( selectedControl ),\n\t\t\t\t\tbindingCanvas,\n\t\t\t\t\t!selectedTarget.IsBound ) );\n\t\t\t}\n\t\t}\n\n\t\tvar bindingRow = RigAuditPanel.Row( bindingCanvas );\n\t\tbindingRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t_controller.Document.Binding.Configuration == GripConfiguration.TwoHanded\n\t\t\t\t? \"Two handed\"\n\t\t\t\t: \"One handed\",\n\t\t\t\"pan_tool\",\n\t\t\tToggleGripConfiguration,\n\t\t\tbindingRow ), 1 );\n\t\tbindingRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Save grip pose\",\n\t\t\t\"save\",\n\t\t\tSaveGripPose,\n\t\t\tbindingRow,\n\t\t\ttrue ), 1 );\n\t\tbindingCanvas.Layout.Add( bindingRow );\n\t\tbindingCanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Apply saved grip pose\",\n\t\t\t\"front_hand\",\n\t\t\tShowGripPoseMenu,\n\t\t\tbindingCanvas ) );\n\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tif ( clip is not null && !_controlToolsOnly )\n\t\t{\n\t\t\t_canvas.Layout.Add( Header( \"CLIP PROPERTIES\" ) );\n\t\t\t_canvas.Layout.Add( NumericField(\n\t\t\t\t\"Duration (seconds)\",\n\t\t\t\tclip.Duration,\n\t\t\t\tvalue => _controller.Mutate( \"Clip duration\", _ =>\n\t\t\t\t{\n\t\t\t\t\tclip.Duration = MathF.Max( value, 1.0f / clip.SampleRate );\n\t\t\t\t\tclip.KeysClampToDuration();\n\t\t\t\t} ) ) );\n\t\t\t_canvas.Layout.Add( NumericField(\n\t\t\t\t\"Sample rate\",\n\t\t\t\tclip.SampleRate,\n\t\t\t\tvalue => _controller.Mutate( \"Clip sample rate\", _ =>\n\t\t\t\t\tclip.SampleRate = Math.Clamp( value, 1, 240 ) ) ) );\n\t\t\t_canvas.Layout.Add( ChoiceButton(\n\t\t\t\t\"Readiness\",\n\t\t\t\t() => clip.Readiness.ToString(),\n\t\t\t\tEnum.GetNames<ClipReadiness>(),\n\t\t\t\tvalue => _controller.Mutate(\n\t\t\t\t\t\"Clip readiness\",\n\t\t\t\t\t_ => clip.Readiness = Enum.Parse<ClipReadiness>( value ) ) ) );\n\t\t\t_canvas.Layout.Add( ChoiceButton(\n\t\t\t\t\"Interpolation\",\n\t\t\t\t() => DominantInterpolation( clip ).ToString(),\n\t\t\t\tEnum.GetNames<TrackInterpolation>(),\n\t\t\t\tvalue => _controller.Mutate( \"Track interpolation\", _ =>\n\t\t\t\t{\n\t\t\t\t\tvar interpolation = Enum.Parse<TrackInterpolation>( value );\n\t\t\t\t\tforeach ( var track in clip.Tracks )\n\t\t\t\t\t\ttrack.Interpolation = interpolation;\n\t\t\t\t} ) ) );\n\t\t}\n\n\t\tvar constraintCanvas = _controlToolsOnly\n\t\t\t? AddCollapsibleSection( \"CONSTRAINTS\", \"constraints\" )\n\t\t\t: _canvas;\n\t\tif ( !_controlToolsOnly )\n\t\t\tconstraintCanvas.Layout.Add( Header( \"KEYING + CONSTRAINTS\" ) );\n\t\tif ( !_controlToolsOnly )\n\t\t{\n\t\t\tvar toggles = RigAuditPanel.Row( constraintCanvas );\n\t\t\ttoggles.Layout.Add( ToggleButton(\n\t\t\t\t\"Auto-key\",\n\t\t\t\t_controller.Document.Workspace.AutoKey,\n\t\t\t\tvalue => _controller.Mutate( \"Auto-key\", d => d.Workspace.AutoKey = value ),\n\t\t\t\ttoggles ), 1 );\n\t\t\ttoggles.Layout.Add( ToggleButton(\n\t\t\t\t\"Local gizmo\",\n\t\t\t\t_controller.Document.Workspace.LocalGizmos,\n\t\t\t\tvalue => _controller.Mutate( \"Gizmo space\", d => d.Workspace.LocalGizmos = value ),\n\t\t\t\ttoggles ), 1 );\n\t\t\tconstraintCanvas.Layout.Add( toggles );\n\t\t}\n\t\tconstraintCanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Constraint target\",\n\t\t\t\"target\",\n\t\t\tShowConstraintTargetMenu,\n\t\t\tconstraintCanvas ) );\n\t\tconstraintCanvas.Layout.Add( WeaponAnimatorTheme.Label(\n\t\t\tstring.IsNullOrWhiteSpace( _controller.Document.Workspace.ConstraintTargetBone )\n\t\t\t\t? \"No constraint target selected\"\n\t\t\t\t: _controller.Document.Workspace.ConstraintTargetBone,\n\t\t\tconstraintCanvas,\n\t\t\ttrue ) );\n\t\tconstraintCanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Constrain selected control\",\n\t\t\t\"link\",\n\t\t\tAddConstraint,\n\t\t\tconstraintCanvas ) );\n\n\t\tif ( !_controlToolsOnly )\n\t\t{\n\t\t\t_canvas.Layout.Add( Header( \"TAGS\" ) );\n\t\t\tvar tagRow = RigAuditPanel.Row( _canvas );\n\t\t\tvar tagName = new LineEdit( tagRow )\n\t\t\t{\n\t\t\t\tPlaceholderText = \"Tag name\",\n\t\t\t\tFixedHeight = 28\n\t\t\t};\n\t\t\ttagName.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t\ttagRow.Layout.Add( tagName, 1 );\n\t\t\ttagRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\t\"Point\",\n\t\t\t\t\"add_location\",\n\t\t\t\t() => AddTag( tagName.Text, AnimationTagKind.Point ),\n\t\t\t\ttagRow ) );\n\t\t\ttagRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\t\"Range\",\n\t\t\t\t\"linear_scale\",\n\t\t\t\t() => AddTag( tagName.Text, AnimationTagKind.Range ),\n\t\t\t\ttagRow ) );\n\t\t\t_canvas.Layout.Add( tagRow );\n\n\t\t\tif ( clip is not null )\n\t\t\t{\n\t\t\t\tforeach ( var tag in clip.Tags )\n\t\t\t\t\t_canvas.Layout.Add( WeaponAnimatorTheme.Label(\n\t\t\t\t\t\t$\"{tag.Name}  {tag.StartTime:0.###}\u2013{tag.EndTime:0.###}\",\n\t\t\t\t\t\t_canvas,\n\t\t\t\t\t\ttrue ) );\n\t\t\t}\n\t\t}\n\n\t\tvar graphCanvas = _controlToolsOnly\n\t\t\t? AddCollapsibleSection( \"ANIMGRAPH PREVIEW\", \"animgraph\" )\n\t\t\t: _canvas;\n\t\tif ( !_controlToolsOnly )\n\t\t\tgraphCanvas.Layout.Add( Header( \"ANIMGRAPH PREVIEW\" ) );\n\t\tvar graphActions = new[]\n\t\t{\n\t\t\t(\"Fire\", \"b_attack\", WeaponClipRole.Fire),\n\t\t\t(\"Dry\", \"b_attack_dry\", WeaponClipRole.FireDry),\n\t\t\t(\"Reload\", \"b_reload\", WeaponClipRole.Reload),\n\t\t\t(\"Sprint\", \"b_sprint\", WeaponClipRole.Sprint),\n\t\t\t(\"Inspect\", \"b_inspect\", WeaponClipRole.Inspect)\n\t\t};\n\t\tvar graphRows = new[]\n\t\t{\n\t\t\tRigAuditPanel.Row( graphCanvas ),\n\t\t\tRigAuditPanel.Row( graphCanvas )\n\t\t};\n\t\tfor ( var index = 0; index < graphActions.Length; index++ )\n\t\t{\n\t\t\tvar captured = graphActions[index];\n\t\t\tvar row = graphRows[index < 3 ? 0 : 1];\n\t\t\trow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\tcaptured.Item1,\n\t\t\t\t\"play_arrow\",\n\t\t\t\t() => SimulateParameter( captured.Item2, captured.Item3 ),\n\t\t\t\trow ), 1 );\n\t\t}\n\t\tgraphCanvas.Layout.Add( graphRows[0] );\n\t\tgraphCanvas.Layout.Add( graphRows[1] );\n\t\tgraphCanvas.Layout.Add( NumericField(\n\t\t\t\"move_bob\",\n\t\t\t_controller.Document.Graph.PreviewFloats.GetValueOrDefault( \"move_bob\" ),\n\t\t\tvalue => _controller.Mutate( \"Preview move_bob\", d =>\n\t\t\t\td.Graph.PreviewFloats[\"move_bob\"] = Math.Clamp( value, 0, 1 ) ),\n\t\t\tgraphCanvas ) );\n\t\t_canvas.Layout.AddStretchCell();\n\t}\n\n\tprivate Widget AddCollapsibleSection( string title, string id )\n\t{\n\t\tvar expanded = _expandedSections.GetValueOrDefault( id );\n\t\tvar header = new WeaponAnimatorButton(\n\t\t\t$\"{(expanded ? \"\u25be\" : \"\u25b8\")}  {title}\",\n\t\t\t_canvas )\n\t\t{\n\t\t\tClicked = () =>\n\t\t\t{\n\t\t\t\t_expandedSections[id] = !expanded;\n\t\t\t\tRebuild();\n\t\t\t},\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\theader.FixedHeight = 26;\n\t\t_canvas.Layout.Add( header );\n\n\t\tvar body = new Widget( _canvas )\n\t\t{\n\t\t\tVisible = expanded,\n\t\t\tLayout = Layout.Column()\n\t\t};\n\t\tbody.Layout.Margin = new Sandbox.UI.Margin( 2, 2, 2, 5 );\n\t\tbody.Layout.Spacing = 6;\n\t\t_canvas.Layout.Add( body );\n\t\treturn body;\n\t}\n\n\tprivate void ToggleHandBinding( string controlName )\n\t{\n\t\tvar target = ResolveControl( controlName );\n\t\tif ( target is null )\n\t\t\treturn;\n\t\tif ( !target.IsBound\n\t\t\t&& controlName == \"@primary_hand\"\n\t\t\t&& _controller.Document.Calibration.GetAnchor( AnchorKind.Grip ) is null )\n\t\t{\n\t\t\tStatusChanged?.Invoke(\n\t\t\t\t\"Set the primary grip anchor in Calibrate before binding the primary hand.\",\n\t\t\t\tValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.Mutate(\n\t\t\ttarget.IsBound ? $\"Unbind {target.Name}\" : $\"Bind {target.Name}\",\n\t\t\tdocument =>\n\t\t\t{\n\t\t\t\tvar bindingTarget = ResolveControl( controlName );\n\t\t\t\tif ( bindingTarget is null )\n\t\t\t\t\treturn;\n\n\t\t\t\tif ( !bindingTarget.IsBound && controlName == \"@primary_hand\" )\n\t\t\t\t\tCalibrationBindingSeeder.SeedDefaultPrimaryHand( document );\n\t\t\t\tbindingTarget.IsBound = !bindingTarget.IsBound;\n\t\t\t\tbindingTarget.Reachable = true;\n\n\t\t\t\tvar checklistId = controlName == \"@primary_hand\"\n\t\t\t\t\t? \"primary_hand\"\n\t\t\t\t\t: \"support_hand\";\n\t\t\t\tif ( bindingTarget.IsBound\n\t\t\t\t\t&& !document.Binding.CompletedChecklistItems.Contains( checklistId ) )\n\t\t\t\t\tdocument.Binding.CompletedChecklistItems.Add( checklistId );\n\t\t\t} );\n\t}\n\n\tprivate void SaveGripPose()\n\t{\n\t\tvar document = _controller.Document;\n\t\tvar skeleton = HostSkeletonBuilder.BuildCached( document );\n\t\tvar pose = AnimationPoseEvaluator.Evaluate(\n\t\t\tdocument,\n\t\t\tskeleton,\n\t\t\tdocument.GetSelectedClip(),\n\t\t\tdocument.Workspace.TimelineTime,\n\t\t\tincludeWorkingPose: true );\n\t\t_controller.Mutate( \"Save default grip pose\", d =>\n\t\t{\n\t\t\tvar grip = new GripPose\n\t\t\t{\n\t\t\t\tName = $\"Grip {d.Binding.GripPoses.Count + 1}\",\n\t\t\t\tBones = skeleton.Bones\n\t\t\t\t\t.Where( x => !x.IsWeaponBone\n\t\t\t\t\t\t&& (x.Name.Contains( \"finger_\", StringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t\t\t|| x.Name.Contains( \"clavicle_\", StringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t\t\t|| x.Name.Contains( \"hand_\", StringComparison.OrdinalIgnoreCase )) )\n\t\t\t\t\t.Select( x => new BonePose\n\t\t\t\t\t{\n\t\t\t\t\t\tBoneName = x.Name,\n\t\t\t\t\t\tLocalTransform = pose.Local[x.Name]\n\t\t\t\t\t} )\n\t\t\t\t\t.ToList()\n\t\t\t};\n\t\t\td.Binding.GripPoses.Add( grip );\n\t\t\td.Binding.DefaultGripPoseId = grip.Id;\n\t\t\td.Binding.CompletedChecklistItems.Add( \"default_grip\" );\n\t\t} );\n\t}\n\n\tprivate void ToggleGripConfiguration()\n\t{\n\t\t_controller.Mutate( \"Grip configuration\", d =>\n\t\t\td.Binding.Configuration = d.Binding.Configuration == GripConfiguration.TwoHanded\n\t\t\t\t? GripConfiguration.OneHanded\n\t\t\t\t: GripConfiguration.TwoHanded );\n\t}\n\n\tprivate void ShowGripPoseMenu()\n\t{\n\t\tif ( _controller.Document.Binding.GripPoses.Count == 0 )\n\t\t{\n\t\t\tStatusChanged?.Invoke( \"No reusable grip poses have been saved.\", ValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\tvar menu = new Menu( this );\n\t\tforeach ( var grip in _controller.Document.Binding.GripPoses )\n\t\t{\n\t\t\tvar captured = grip;\n\t\t\tmenu.AddOption( captured.Name, null, () => ApplyGripPose( captured ) );\n\t\t}\n\t\tmenu.OpenAtCursor();\n\t}\n\n\tprivate void ApplyGripPose( GripPose pose )\n\t{\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tif ( clip is null )\n\t\t\treturn;\n\n\t\t_controller.Mutate( $\"Apply {pose.Name}\", document =>\n\t\t{\n\t\t\tvar time = document.Workspace.TimelineTime;\n\t\t\tforeach ( var bone in pose.Bones )\n\t\t\t{\n\t\t\t\tvar track = clip.EnsureTrack( bone.BoneName );\n\t\t\t\ttrack.Kind = RigControlKind.Arm;\n\t\t\t\tWeaponAnimationMath.UpsertKey( track, time, bone.LocalTransform );\n\t\t\t}\n\t\t\tclip.Readiness = clip.Role == WeaponClipRole.Idle\n\t\t\t\t? ClipReadiness.Ready\n\t\t\t\t: ClipReadiness.Draft;\n\t\t\tdocument.Binding.DefaultGripPoseId = pose.Id;\n\t\t} );\n\t}\n\n\tprivate void AddConstraint()\n\t{\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tvar source = _controller.Document.Workspace.SelectedControl;\n\t\tvar target = _controller.Document.Workspace.ConstraintTargetBone;\n\t\tif ( clip is null || string.IsNullOrWhiteSpace( source ) || string.IsNullOrWhiteSpace( target ) )\n\t\t{\n\t\t\tStatusChanged?.Invoke(\n\t\t\t\t\"Select an arm control and a weapon bone before adding a constraint.\",\n\t\t\t\tValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.Mutate( \"Add timed constraint\", _ => clip.Constraints.Add( new TimedConstraint\n\t\t{\n\t\t\tSourceControl = source,\n\t\t\tTargetBone = target,\n\t\t\tStartTime = _controller.Document.Workspace.TimelineTime,\n\t\t\tEndTime = clip.Duration\n\t\t} ) );\n\t}\n\n\tprivate void ShowConstraintTargetMenu()\n\t{\n\t\tvar menu = new Menu( this );\n\t\tvar weaponBones = _controller.Document.Rig.RetainedBones()\n\t\t\t.OrderBy( x => x.Name );\n\t\tforeach ( var bone in weaponBones )\n\t\t{\n\t\t\tvar captured = bone.Name;\n\t\t\tmenu.AddOption( captured, null, () => _controller.Mutate(\n\t\t\t\t\"Constraint target\",\n\t\t\t\td => d.Workspace.ConstraintTargetBone = captured ) );\n\t\t}\n\t\tmenu.OpenAtCursor();\n\t}\n\n\tprivate void AddTag( string name, AnimationTagKind kind )\n\t{\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tif ( clip is null || string.IsNullOrWhiteSpace( name ) )\n\t\t\treturn;\n\t\t_controller.Mutate( $\"Add tag {name}\", document =>\n\t\t{\n\t\t\tvar start = document.Workspace.TimelineTime;\n\t\t\tclip.Tags.Add( new AnimationTag\n\t\t\t{\n\t\t\t\tName = name.Trim(),\n\t\t\t\tKind = kind,\n\t\t\t\tStartTime = start,\n\t\t\t\tEndTime = kind == AnimationTagKind.Range\n\t\t\t\t\t? MathF.Min( start + 0.1f, clip.Duration )\n\t\t\t\t\t: start\n\t\t\t} );\n\t\t} );\n\t}\n\n\tprivate void SimulateParameter( string name, WeaponClipRole role )\n\t{\n\t\tvar clip = _controller.Document.Clips.FirstOrDefault( x => x.Role == role );\n\t\tif ( clip is null )\n\t\t\treturn;\n\t\t_controller.Document.Graph.PreviewBools[name] = true;\n\t\t_controller.SelectClip( clip.Id );\n\t\tStatusChanged?.Invoke(\n\t\t\t$\"Simulating {name}=true with {(clip.Readiness == ClipReadiness.NotStarted ? \"Idle fallback\" : clip.Name)}.\",\n\t\t\tclip.Readiness == ClipReadiness.NotStarted ? ValidationSeverity.Warning : ValidationSeverity.Info );\n\t}\n\n\tprivate string SelectionName()\n\t{\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tif ( !string.IsNullOrWhiteSpace( workspace.SelectedControl ) )\n\t\t\treturn workspace.SelectedControl.TrimStart( '@' ).Replace( '_', ' ' );\n\t\tif ( !string.IsNullOrWhiteSpace( workspace.SelectedBone ) )\n\t\t\treturn workspace.SelectedBone;\n\t\treturn \"No control selected\";\n\t}\n\n\tprivate RigTarget? ResolveControl( string name ) => name switch\n\t{\n\t\t\"@primary_hand\" => _controller.Document.Binding.PrimaryHand,\n\t\t\"@support_hand\" => _controller.Document.Binding.SupportHand,\n\t\t\"@primary_elbow\" => _controller.Document.Binding.PrimaryElbowPole,\n\t\t\"@support_elbow\" => _controller.Document.Binding.SupportElbowPole,\n\t\t_ => null\n\t};\n\n\tprivate Widget NumericField(\n\t\tstring name,\n\t\tfloat value,\n\t\tAction<float> changed,\n\t\tWidget? parent = null )\n\t{\n\t\tparent ??= _canvas;\n\t\tvar row = RigAuditPanel.Row( parent );\n\t\trow.Layout.Add( WeaponAnimatorTheme.Label( name, row, true ), 1 );\n\t\tvar edit = new LineEdit( row )\n\t\t{\n\t\t\tText = value.ToString( \"0.###\", CultureInfo.InvariantCulture ),\n\t\t\tFixedHeight = 26,\n\t\t\tFixedWidth = 86\n\t\t};\n\t\tedit.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\tedit.EditingFinished += () =>\n\t\t{\n\t\t\tif ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )\n\t\t\t\tchanged( parsed );\n\t\t};\n\t\trow.Layout.Add( edit );\n\t\treturn row;\n\t}\n\n\tprivate Button ChoiceButton(\n\t\tstring label,\n\t\tFunc<string> current,\n\t\tIEnumerable<string> values,\n\t\tAction<string> changed,\n\t\tWidget? parent = null )\n\t{\n\t\tparent ??= _canvas;\n\t\tvar button = new WeaponAnimatorButton( $\"{label}: {current()}\", \"expand_more\", parent )\n\t\t{\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Clicked = () =>\n\t\t{\n\t\t\tvar menu = new Menu( button );\n\t\t\tforeach ( var value in values )\n\t\t\t{\n\t\t\t\tvar captured = value;\n\t\t\t\tmenu.AddOption( captured, null, () =>\n\t\t\t\t{\n\t\t\t\t\tchanged( captured );\n\t\t\t\t\tbutton.Text = $\"{label}: {current()}\";\n\t\t\t\t\tbutton.FitToContent();\n\t\t\t\t} );\n\t\t\t}\n\t\t\tmenu.OpenAt( button.ScreenRect.BottomLeft );\n\t\t};\n\t\treturn button;\n\t}\n\n\tprivate static Button ToggleButton(\n\t\tstring text,\n\t\tbool value,\n\t\tAction<bool> changed,\n\t\tWidget parent )\n\t{\n\t\tvar button = new WeaponAnimatorButton( text, parent )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tIsChecked = value,\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Toggled = () => changed( button.IsChecked );\n\t\treturn button;\n\t}\n\n\tprivate Label Header( string text )\n\t{\n\t\treturn WeaponAnimatorTheme.SectionLabel( text, _canvas, topMargin: true );\n\t}\n\n\tprivate static TrackInterpolation DominantInterpolation( WeaponAnimationClip clip ) =>\n\t\tclip.Tracks.GroupBy( x => x.Interpolation )\n\t\t\t.OrderByDescending( x => x.Count() )\n\t\t\t.Select( x => x.Key )\n\t\t\t.FirstOrDefault();\n}\n\npublic sealed class AnimationTimelinePanel : Widget\n{\n\tprivate readonly WeaponAnimatorController _controller;\n\tprivate readonly TimelineEditorCanvas _timeline;\n\tprivate readonly TimelineControlToolbar _toolbar;\n\tprivate readonly Label _timeLabel;\n\tprivate readonly WeaponAnimatorButton _playButton;\n\tprivate readonly WeaponAnimatorButton _curvesButton;\n\tprivate readonly WeaponAnimatorButton _loopButton;\n\n\tpublic AnimationTimelinePanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )\n\t{\n\t\t_controller = controller;\n\t\tLayout = Layout.Column();\n\t\tLayout.Margin = 0;\n\t\tLayout.Spacing = 0;\n\n\t\t_toolbar = new TimelineControlToolbar( this );\n\t\tvar left = _toolbar.LeftSection;\n\t\tleft.Layout.Add( CompactAction( \"Add key\", \"key\", AddKey, left, true ) );\n\t\tleft.Layout.Add( CompactAction( \"Copy\", \"content_copy\", _controller.CopySelectedKeys, left ) );\n\t\tleft.Layout.Add( CompactAction( \"Paste\", \"content_paste\", _controller.PasteKeys, left ) );\n\t\tleft.Layout.Add( CompactAction( \"Delete\", \"delete\", _controller.DeleteSelectedKeys, left ) );\n\t\tvar reverse = CompactAction( \"Reverse\", \"swap_horiz\", _controller.ReverseKeys, left );\n\t\treverse.ToolTip =\n\t\t\t\"Reverse selected keys within their time range. With no selection, reverse the whole clip.\";\n\t\tleft.Layout.Add( reverse );\n\t\t_curvesButton = CompactAction(\n\t\t\t\"Curves\",\n\t\t\t\"show_chart\",\n\t\t\t() => _controller.SetCurveEditorVisible(\n\t\t\t\t!_controller.Document.Workspace.CurveEditorVisible ),\n\t\t\tleft );\n\t\t_curvesButton.IsToggle = true;\n\t\tleft.Layout.Add( _curvesButton );\n\n\t\tvar player = _toolbar.CenterSection;\n\t\tplayer.Layout.Spacing = 3;\n\t\tplayer.Layout.Add( new Widget( player )\n\t\t{\n\t\t\tFixedWidth = 28,\n\t\t\tMinimumWidth = 28,\n\t\t\tFixedHeight = 26\n\t\t} );\n\t\tplayer.Layout.Add( PlayerButton( \"first_page\", \"Jump to first frame\", _controller.JumpToFirstFrame, player ) );\n\t\tplayer.Layout.Add( PlayerButton( \"skip_previous\", \"Previous frame\", () => _controller.StepTimelineFrame( -1 ), player ) );\n\t\t_playButton = PlayerButton( \"play_arrow\", \"Play\", _controller.TogglePlayback, player );\n\t\tplayer.Layout.Add( _playButton );\n\t\tplayer.Layout.Add( PlayerButton( \"skip_next\", \"Next frame\", () => _controller.StepTimelineFrame( 1 ), player ) );\n\t\tplayer.Layout.Add( PlayerButton( \"last_page\", \"Jump to last frame\", _controller.JumpToLastFrame, player ) );\n\t\t_loopButton = PlayerButton(\n\t\t\t\"repeat\",\n\t\t\t\"Loop playback\",\n\t\t\t_controller.ToggleSelectedClipLoop,\n\t\t\tplayer );\n\t\t_loopButton.IsToggle = true;\n\t\t_loopButton.Flat = true;\n\t\tplayer.Layout.Add( _loopButton );\n\n\t\tvar right = _toolbar.RightSection;\n\t\tright.Layout.AddStretchCell();\n\t\t_timeLabel = WeaponAnimatorTheme.Label( \"\", right );\n\t\tright.Layout.Add( _timeLabel );\n\t\t_toolbar.FitSections();\n\t\tLayout.Add( _toolbar );\n\n\t\t_timeline = new TimelineEditorCanvas( controller, this );\n\t\tLayout.Add( _timeline, 1 );\n\t\t_controller.DocumentChanged += Refresh;\n\t\t_controller.TimelineChanged += Refresh;\n\t\t_controller.TimelineViewChanged += Refresh;\n\t\t_controller.PlaybackChanged += Refresh;\n\t\t_controller.ClipPlaybackSettingsChanged += Refresh;\n\t\tRefresh();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t_controller.DocumentChanged -= Refresh;\n\t\t_controller.TimelineChanged -= Refresh;\n\t\t_controller.TimelineViewChanged -= Refresh;\n\t\t_controller.PlaybackChanged -= Refresh;\n\t\t_controller.ClipPlaybackSettingsChanged -= Refresh;\n\t\tbase.OnDestroyed();\n\t}\n\n\tprivate void AddKey()\n\t\t=> _controller.KeySelectedTransform();\n\n\tprivate void Refresh()\n\t{\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tif ( clip is null )\n\t\t\t_timeLabel.Text = \"No clip\";\n\t\telse\n\t\t{\n\t\t\tvar frame = TimelineInteraction.TimeToFrame(\n\t\t\t\t_controller.Document.Workspace.TimelineTime,\n\t\t\t\tclip.SampleRate );\n\t\t\tvar total = TimelineInteraction.LastFrame( clip );\n\t\t\t_timeLabel.Text =\n\t\t\t\t$\"{_controller.Document.Workspace.TimelineTime:0.000}s \u00b7 {frame:00} / {total:00}\";\n\t\t}\n\t\t_playButton.Icon = _controller.IsPlaying ? \"pause\" : \"play_arrow\";\n\t\t_playButton.ToolTip = _controller.IsPlaying ? \"Pause\" : \"Play\";\n\t\t_loopButton.Enabled = clip is not null;\n\t\t_loopButton.IsChecked = clip?.Loop == true;\n\t\t_loopButton.Tint = clip?.Loop == true\n\t\t\t? WeaponAnimatorTheme.Cyan\n\t\t\t: WeaponAnimatorTheme.Muted;\n\t\t_loopButton.ToolTip = clip?.Loop == true\n\t\t\t? \"Loop playback is enabled\"\n\t\t\t: \"Loop playback\";\n\t\tvar curves = _controller.Document.Workspace.CurveEditorVisible;\n\t\t_curvesButton.IsChecked = curves;\n\t\t_curvesButton.Text = curves ? \"Keys\" : \"Curves\";\n\t\t_curvesButton.Icon = curves ? \"view_timeline\" : \"show_chart\";\n\t\t_curvesButton.Tint = curves\n\t\t\t? WeaponAnimatorTheme.Cyan * 0.65f\n\t\t\t: WeaponAnimatorTheme.SurfaceRaised;\n\t\t_curvesButton.ToolTip = curves\n\t\t\t? \"Return to the keyframe view\"\n\t\t\t: \"Open the curve editor\";\n\t\t_curvesButton.FitToContent( true );\n\t\t_toolbar.FitSections();\n\t\t_timeline.Update();\n\t}\n\n\tprivate static WeaponAnimatorButton CompactAction(\n\t\tstring text,\n\t\tstring icon,\n\t\tAction clicked,\n\t\tWidget parent,\n\t\tbool primary = false )\n\t{\n\t\tvar button = (WeaponAnimatorButton)WeaponAnimatorTheme.Button(\n\t\t\ttext,\n\t\t\ticon,\n\t\t\tclicked,\n\t\t\tparent,\n\t\t\tprimary );\n\t\tbutton.FixedHeight = 26;\n\t\tbutton.FitToContent( true );\n\t\treturn button;\n\t}\n\n\tprivate static WeaponAnimatorButton PlayerButton(\n\t\tstring icon,\n\t\tstring tooltip,\n\t\tAction clicked,\n\t\tWidget parent )\n\t{\n\t\tvar button = new WeaponAnimatorButton( \"\", icon, parent )\n\t\t{\n\t\t\tClicked = clicked,\n\t\t\tFixedWidth = 28,\n\t\t\tFixedHeight = 26,\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised,\n\t\t\tToolTip = tooltip\n\t\t};\n\t\treturn button;\n\t}\n}\n\ninternal sealed class TimelineControlToolbar : Widget\n{\n\tpublic Widget LeftSection { get; }\n\tpublic Widget CenterSection { get; }\n\tpublic Widget RightSection { get; }\n\n\tpublic TimelineControlToolbar( Widget? parent = null ) : base( parent )\n\t{\n\t\tFixedHeight = 34;\n\t\tSetStyles( \"background-color: rgb(24,27,30); border: none;\" );\n\t\tLayout = Layout.Row();\n\t\tLayout.Margin = new Sandbox.UI.Margin( 7, 4, 7, 4 );\n\t\tLayout.Spacing = 0;\n\n\t\tLeftSection = Section( this );\n\t\tCenterSection = Section( this );\n\t\tRightSection = Section( this );\n\t\tLayout.Add( LeftSection );\n\t\tLayout.AddStretchCell();\n\t\tLayout.Add( RightSection );\n\t\tCenterSection.Raise();\n\t}\n\n\tpublic void FitSections()\n\t{\n\t\tLeftSection.FixedWidth = SectionWidth( LeftSection );\n\t\tCenterSection.FixedWidth = SectionWidth( CenterSection );\n\t\tPositionCenter();\n\t}\n\n\tprotected override void OnResize()\n\t{\n\t\tbase.OnResize();\n\t\tPositionCenter();\n\t}\n\n\tprivate void PositionCenter()\n\t{\n\t\tCenterSection.Position = new Vector2(\n\t\t\tCenteredLeft( Width, CenterSection.Width ),\n\t\t\tMathF.Round( (Height - CenterSection.Height) * 0.5f ) );\n\t\tCenterSection.Raise();\n\t}\n\n\tinternal static float CenteredLeft( float toolbarWidth, float sectionWidth ) =>\n\t\tMathF.Round( (toolbarWidth - sectionWidth) * 0.5f );\n\n\tprivate static float SectionWidth( Widget section )\n\t{\n\t\tvar children = section.Children.ToArray();\n\t\tif ( children.Length == 0 )\n\t\t\treturn 0;\n\t\treturn children.Sum( x => x is WeaponAnimatorButton button\n\t\t\t? string.IsNullOrWhiteSpace( button.Text )\n\t\t\t\t? 28\n\t\t\t\t: MathF.Ceiling( button.PreferredWidth )\n\t\t\t: MathF.Max( x.MinimumWidth, 0 ) )\n\t\t\t+ MathF.Max( children.Length - 1, 0 ) * section.Layout.Spacing;\n\t}\n\n\tprivate static Widget Section( Widget parent )\n\t{\n\t\tvar section = new Widget( parent )\n\t\t{\n\t\t\tLayout = Layout.Row(),\n\t\t\tFixedHeight = 26\n\t\t};\n\t\tsection.SetStyles( \"background-color: transparent; border: none;\" );\n\t\tsection.Layout.Margin = 0;\n\t\tsection.Layout.Spacing = 4;\n\t\treturn section;\n\t}\n}\n\ninternal static class ClipExtensions\n{\n\tpublic static void KeysClampToDuration( this WeaponAnimationClip clip )\n\t{\n\t\tforeach ( var key in clip.Tracks.SelectMany( x => x.Keys ) )\n\t\t\tkey.Time = Math.Clamp( key.Time, 0, clip.Duration );\n\t\tforeach ( var key in clip.VisibilityTracks.SelectMany( x => x.Keys ) )\n\t\t\tkey.Time = Math.Clamp( key.Time, 0, clip.Duration );\n\t\tforeach ( var tag in clip.Tags )\n\t\t{\n\t\t\ttag.StartTime = Math.Clamp( tag.StartTime, 0, clip.Duration );\n\t\t\ttag.EndTime = Math.Clamp( tag.EndTime, tag.StartTime, clip.Duration );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Widgets/WeaponAnimatorViewport.cs",
            "FileName": "WeaponAnimatorViewport.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\ninternal readonly record struct GridVisualStyle(\n\tfloat MinorOpacity,\n\tfloat MajorOpacity,\n\tfloat AxisOpacity,\n\tfloat MinorWidth,\n\tfloat MajorWidth,\n\tfloat AxisWidth )\n{\n\tpublic static GridVisualStyle Resolve( float opacity, float lineWeight )\n\t{\n\t\tvar alpha = Math.Clamp( opacity, 0, 0.5f );\n\t\tvar weight = Math.Clamp( lineWeight, 0.1f, 2.0f );\n\t\treturn new GridVisualStyle(\n\t\t\talpha * 0.42f,\n\t\t\talpha * 0.70f,\n\t\t\talpha,\n\t\t\tweight * 0.38f,\n\t\t\tweight * 0.58f,\n\t\t\tweight * 0.78f );\n\t}\n}\n\ninternal readonly record struct ViewportRimLightStyle(\n\tbool Enabled,\n\tfloat Intensity,\n\tColor Color )\n{\n\tpublic static ViewportRimLightStyle Resolve(\n\t\tbool enabled,\n\t\tfloat intensity,\n\t\tbool fullBright )\n\t{\n\t\tvar safeIntensity = WeaponAnimationMath.IsFinite( intensity )\n\t\t\t? Math.Clamp( intensity, 0, 12 )\n\t\t\t: 4.0f;\n\t\treturn new ViewportRimLightStyle(\n\t\t\tenabled && !fullBright && safeIntensity > 0.001f,\n\t\t\tsafeIntensity,\n\t\t\tWeaponAnimatorTheme.Cyan * safeIntensity );\n\t}\n}\n\ninternal enum SkeletonBoneKind\n{\n\tWeapon,\n\tArm,\n\tTwist,\n\tIk\n}\n\n/// <summary>\n/// <paramref name=\"Hollow\"/> draws the bone as a wireframe orb instead of a filled dot. Solid means\n/// \"you pose this directly\"; hollow means the bone is derived - driven by a constraint or kept only\n/// as an export helper. Shape reads at a glance where a size difference alone does not.\n/// </summary>\ninternal readonly record struct SkeletonBoneStyle(\n\tbool Visible,\n\tColor Color,\n\tfloat AlphaScale,\n\tfloat RadiusScale,\n\tbool Hollow )\n{\n\t/// <summary>\n\t/// The Facepunch arms ship four IK helper bones (`hand_*_to_*_ikrule`) kept through compilation\n\t/// by BoneMarkup even though they skin nothing, and the host builder adds `ik_hand_R`/`ik_hand_L`\n\t/// parented to weapon_root. Nothing reads any of them, and their default binding offset puts\n\t/// them well in front of the weapon, so they trail long lines across the viewport.\n\t/// </summary>\n\tpublic static SkeletonBoneKind Classify( HostBone bone )\n\t{\n\t\t// Checked ahead of the weapon test on purpose: weapon rigs commonly ship their own IK\n\t\t// targets (weapon_IK_hand_R), and those are helpers whichever rig they arrived from.\n\t\t// Hiding is display-only, so a false positive costs visibility, never generated output.\n\t\tif ( HasIkToken( bone.Name ) )\n\t\t\treturn SkeletonBoneKind.Ik;\n\t\tif ( bone.IsWeaponBone )\n\t\t\treturn SkeletonBoneKind.Weapon;\n\n\t\t// Twist bones deform the mesh, so they stay visible and clickable - just quieter.\n\t\treturn bone.Name.Contains( \"_twist\", StringComparison.OrdinalIgnoreCase )\n\t\t\t? SkeletonBoneKind.Twist\n\t\t\t: SkeletonBoneKind.Arm;\n\t}\n\n\t/// <summary>\n\t/// Matches `ik` and `ikrule` as whole underscore-delimited tokens rather than as substrings, so\n\t/// `weapon_IK_hand_R` and `hand_R_to_weapon_ikrule` are caught while ordinary names that merely\n\t/// contain the letters - `spike`, `strike_plate` - are not.\n\t/// </summary>\n\tprivate static bool HasIkToken( string name )\n\t{\n\t\tforeach ( var token in name.Split( '_', StringSplitOptions.RemoveEmptyEntries ) )\n\t\t{\n\t\t\tif ( token.Equals( \"ik\", StringComparison.OrdinalIgnoreCase )\n\t\t\t\t|| token.Equals( \"ikrule\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tpublic static SkeletonBoneStyle Resolve(\n\t\tSkeletonBoneKind kind,\n\t\tint depth,\n\t\tint maxDepth,\n\t\tbool showIk )\n\t{\n\t\tif ( kind == SkeletonBoneKind.Weapon )\n\t\t\treturn new SkeletonBoneStyle( true, WeaponAnimatorTheme.Amber, 1.0f, 1.0f, false );\n\t\tif ( kind == SkeletonBoneKind.Ik )\n\t\t\treturn new SkeletonBoneStyle( showIk, WeaponAnimatorTheme.Coral, 1.0f, 1.0f, true );\n\n\t\tvar fraction = maxDepth > 0\n\t\t\t? Math.Clamp( depth / (float)maxDepth, 0, 1 )\n\t\t\t: 0;\n\t\tvar color = WeaponAnimatorTheme.BoneDepthColor( fraction );\n\n\t\t// Twist bones are driven by TiltTwist constraints, so they read as hollow. They keep close\n\t\t// to full size because a wireframe orb needs the room to be legible at all.\n\t\treturn kind == SkeletonBoneKind.Twist\n\t\t\t? new SkeletonBoneStyle( true, color, 0.7f, 0.9f, true )\n\t\t\t: new SkeletonBoneStyle( true, color, 1.0f, 1.0f, false );\n\t}\n}\n\ninternal readonly record struct SkeletonOverlayStyle(\n\tbool DrawThroughMeshes,\n\tfloat VisibleAlpha,\n\tfloat OccludedAlpha )\n{\n\t/// <summary>\n\t/// Occluded bones use smaller marks so they stay readable without competing with visible bones.\n\t/// </summary>\n\tpublic const float OccludedDotScale = 0.55f;\n\tpublic const float OccludedLineThickness = 0.6f;\n\tpublic const int OcclusionGradientSegments = 10;\n\n\t/// <summary>\n\t/// Pushes an occluded bone most of the way to grey. Hue carries depth along the arm chain, so\n\t/// draining it is what makes \"behind something\" read as a different category rather than just a\n\t/// dimmer version of the same thing. A little colour is left so weapon and arm stay tellable.\n\t/// </summary>\n\tpublic static Color Occlude( Color color )\n\t{\n\t\tvar luminance = (color.r * 0.299f) + (color.g * 0.587f) + (color.b * 0.114f);\n\t\treturn Color.Lerp(\n\t\t\tcolor,\n\t\t\tnew Color( luminance, luminance, luminance, color.a ),\n\t\t\t0.8f );\n\t}\n\n\tpublic static float OcclusionDepthClearance( float cameraDistance )\n\t{\n\t\tvar safeDistance = WeaponAnimationMath.IsFinite( cameraDistance )\n\t\t\t? MathF.Max( cameraDistance, 0 )\n\t\t\t: 0;\n\t\tvar markerRadius = Math.Clamp( safeDistance / 180.0f, 0.08f, 0.45f );\n\t\treturn MathF.Max( markerRadius * 0.35f, 0.05f );\n\t}\n\n\tpublic static bool IsOccludingDepth(\n\t\tfloat targetDistance,\n\t\tfloat hitDistance )\n\t{\n\t\treturn WeaponAnimationMath.IsFinite( targetDistance )\n\t\t\t&& WeaponAnimationMath.IsFinite( hitDistance )\n\t\t\t&& targetDistance - hitDistance > OcclusionDepthClearance( targetDistance );\n\t}\n\n\tpublic static SkeletonOverlayStyle Resolve( bool xray, float baseAlpha )\n\t{\n\t\tvar safe = WeaponAnimationMath.IsFinite( baseAlpha )\n\t\t\t? Math.Clamp( baseAlpha, 0, 1 )\n\t\t\t: 1.0f;\n\t\treturn new SkeletonOverlayStyle(\n\t\t\txray && safe > 0.001f,\n\t\t\tsafe,\n\t\t\tsafe * 0.28f );\n\t}\n\n\tpublic SkeletonLineVisual ResolveLineVisual(\n\t\tSkeletonBoneStyle bone,\n\t\tbool occluded )\n\t{\n\t\tvar color = occluded ? Occlude( bone.Color ) : bone.Color;\n\t\tvar alpha = (occluded ? OccludedAlpha : VisibleAlpha)\n\t\t\t* bone.AlphaScale\n\t\t\t* 0.45f;\n\t\treturn new SkeletonLineVisual(\n\t\t\tcolor.WithAlpha( alpha ),\n\t\t\toccluded ? OccludedLineThickness : 1.0f );\n\t}\n}\n\ninternal readonly record struct SkeletonLineVisual(\n\tColor Color,\n\tfloat Thickness )\n{\n\tpublic static SkeletonLineVisual Lerp(\n\t\tSkeletonLineVisual start,\n\t\tSkeletonLineVisual end,\n\t\tfloat fraction )\n\t{\n\t\tvar t = WeaponAnimationMath.IsFinite( fraction )\n\t\t\t? Math.Clamp( fraction, 0, 1 )\n\t\t\t: 0;\n\t\treturn new SkeletonLineVisual(\n\t\t\tColor.Lerp( start.Color, end.Color, t ),\n\t\t\tstart.Thickness + ((end.Thickness - start.Thickness) * t) );\n\t}\n}\n\ninternal static class SkeletonOcclusionPolicy\n{\n\tpublic static bool IsOccludedByArm(\n\t\tbool targetIsWeaponBone,\n\t\tint targetArmSide,\n\t\tint hitArmSide )\n\t{\n\t\treturn hitArmSide != 0\n\t\t\t&& (targetIsWeaponBone\n\t\t\t\t|| (targetArmSide != 0 && targetArmSide != hitArmSide));\n\t}\n}\n\ninternal readonly record struct ArmPreviewVisualStyle(\n\tbool UseFlatMaterial,\n\tColor Tint )\n{\n\tpublic static ArmPreviewVisualStyle Resolve(\n\t\tWeaponAnimatorStage stage,\n\t\tbool fullBright )\n\t{\n\t\tif ( fullBright )\n\t\t{\n\t\t\treturn new ArmPreviewVisualStyle(\n\t\t\t\ttrue,\n\t\t\t\tnew Color( 0.78f, 0.55f, 0.43f ) );\n\t\t}\n\n\t\treturn stage == WeaponAnimatorStage.Animate\n\t\t\t? new ArmPreviewVisualStyle( false, Color.White )\n\t\t\t: new ArmPreviewVisualStyle(\n\t\t\t\tfalse,\n\t\t\t\tnew Color( 0.42f, 0.84f, 0.92f, 0.42f ) );\n\t}\n}\n\npublic enum WeaponAnimatorTransformMode\n{\n\tMove,\n\tRotate,\n\tScale\n}\n\ninternal sealed class RotationSnapStepWidget : Widget\n{\n\tprivate readonly LineEdit _edit;\n\tprivate readonly Func<float> _getValue;\n\tprivate readonly Action<float> _setValue;\n\n\tpublic RotationSnapStepWidget(\n\t\tFunc<float> getValue,\n\t\tAction<float> setValue,\n\t\tWidget parent ) : base( parent )\n\t{\n\t\t_getValue = getValue;\n\t\t_setValue = setValue;\n\t\tFixedWidth = 55;\n\t\tFixedHeight = 28;\n\t\tToolTip = \"Rotation snap angle\";\n\t\tSetStyles(\n\t\t\t\"background-color: rgb(20,23,26);\" +\n\t\t\t\"border: 1px solid rgba(255,255,255,0.09);\" +\n\t\t\t\"border-radius: 3px;\" );\n\t\tLayout = Layout.Row();\n\t\tLayout.Margin = 0;\n\t\tLayout.Spacing = 0;\n\n\t\t_edit = new LineEdit( this )\n\t\t{\n\t\t\tFixedHeight = 26,\n\t\t\tToolTip = ToolTip\n\t\t};\n\t\t_edit.SetStyles(\n\t\t\t\"background-color: transparent; border: none;\" +\n\t\t\t\"color: rgb(224,229,234); font-size: 11px;\" +\n\t\t\t\"text-align: right; padding: 0 1px 0 2px;\" );\n\t\t_edit.TextEdited += ApplyText;\n\t\t_edit.EditingFinished += Refresh;\n\t\tLayout.Add( _edit, 1 );\n\n\t\tvar suffix = WeaponAnimatorTheme.Label( \"\u00b0\", this );\n\t\tsuffix.FixedWidth = 9;\n\t\tsuffix.Alignment = TextFlag.Center;\n\t\tLayout.Add( suffix );\n\n\t\tvar buttons = Layout.AddColumn();\n\t\tbuttons.Add( new IconButton( \"keyboard_arrow_up\", () => Step( 1 ) )\n\t\t{\n\t\t\tBackground = Color.Transparent,\n\t\t\tFixedWidth = 16,\n\t\t\tFixedHeight = 13,\n\t\t\tIconSize = 12,\n\t\t\tToolTip = \"Increase rotation snap angle\"\n\t\t} );\n\t\tbuttons.Add( new IconButton( \"keyboard_arrow_down\", () => Step( -1 ) )\n\t\t{\n\t\t\tBackground = Color.Transparent,\n\t\t\tFixedWidth = 16,\n\t\t\tFixedHeight = 13,\n\t\t\tIconSize = 12,\n\t\t\tToolTip = \"Decrease rotation snap angle\"\n\t\t} );\n\t\tRefresh();\n\t}\n\n\tpublic void Refresh()\n\t{\n\t\tif ( _edit.IsFocused )\n\t\t\treturn;\n\n\t\t_edit.Text = _getValue().ToString( \"0.##\", CultureInfo.InvariantCulture );\n\t\t_edit.CursorPosition = 0;\n\t\tUpdate();\n\t}\n\n\tprivate void ApplyText( string text )\n\t{\n\t\tif ( float.TryParse(\n\t\t\ttext,\n\t\t\tNumberStyles.Float,\n\t\t\tCultureInfo.InvariantCulture,\n\t\t\tout var value )\n\t\t\t&& WeaponAnimationMath.IsFinite( value ) )\n\t\t\t_setValue( value );\n\t}\n\n\tprivate void Step( int direction )\n\t{\n\t\t_edit.Blur();\n\t\t_setValue( WeaponAnimatorViewport.AdjustRotationSnapAngle(\n\t\t\t_getValue(),\n\t\t\tdirection ) );\n\t\tRefresh();\n\t}\n}\n\npublic sealed class WeaponAnimatorViewport : SceneRenderingWidget\n{\n\tprivate const int LegacyIdleRepairVersion = 3;\n\tprivate const float ScaleGizmoSensitivity = 0.005f;\n\tprivate const string ArmsOccluderTag = \"weaponanim_arms_occluder\";\n\tprivate static readonly float[] RotationSnapSteps =\n\t\t[0.25f, 0.5f, 1, 5, 15, 30, 45, 90, 180];\n\tprivate Rect TransformReadoutRect =>\n\t\tnew( 260, Width < 620 ? 46 : 10, 104, 28 );\n\tprivate readonly WeaponAnimatorController _controller;\n\tprivate readonly CameraComponent _camera;\n\tprivate readonly PointLight _rimLight;\n\tprivate readonly Material _flatArmsMaterial;\n\tprivate readonly WeaponAnimatorButton _moveModeButton;\n\tprivate readonly WeaponAnimatorButton _rotateModeButton;\n\tprivate readonly WeaponAnimatorButton _scaleModeButton;\n\tprivate readonly WeaponAnimatorButton _spaceButton;\n\tprivate readonly WeaponAnimatorButton _rotationSnapButton;\n\tprivate readonly RotationSnapStepWidget _rotationSnapStep;\n\tprivate readonly WeaponAnimatorButton _orbitCameraButton;\n\tprivate readonly WeaponAnimatorButton _freeLookCameraButton;\n\tprivate readonly WeaponAnimatorButton _lightingButton;\n\tprivate string _transformModeText = \"\";\n\tprivate SkinnedModelRenderer? _sourceRenderer;\n\tprivate SkinnedModelRenderer? _armsRenderer;\n\tprivate ModelHitboxes? _armsHitboxes;\n\tprivate SkinnedModelRenderer? _hostRenderer;\n\tprivate HostSkeleton? _hostSkeleton;\n\tprivate HostSkeleton? _boneDepthSource;\n\tprivate readonly Dictionary<string, int> _boneDepths =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate readonly Dictionary<string, Transform> _occlusionPose =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate readonly HashSet<string> _occludedBones =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate Transform _occlusionCameraTransform;\n\tprivate bool _occlusionCacheValid;\n\tprivate bool _occlusionFollowupPending;\n\tprivate string _lastOcclusionDiagnostic = \"\";\n\tprivate RealTimeSince _sinceOcclusionTrace = 99;\n\tprivate int _maxBoneDepth;\n\tprivate string _loadedSource = \"\";\n\tprivate string _loadedHost = \"\";\n\tprivate string _lastDiagnosticSelection = \"\";\n\tprivate int _legacyIdleRepairVersionChecked;\n\tprivate bool _sourcePoseDiagnosticsLogged;\n\tprivate int _sourcePoseDiagnosticFrames;\n\tprivate bool _armPoseDiagnosticsLogged;\n\tprivate int _armPoseDiagnosticFrames;\n\tprivate Vector2 _lastMouse;\n\tprivate string _calibrationGizmoTarget = \"\";\n\tprivate Transform _calibrationGizmoStartWorld;\n\tprivate Transform _calibrationGizmoStartLocal;\n\tprivate Vector3 _calibrationGizmoMoveDelta;\n\tprivate Vector3 _calibrationGizmoScaleDelta;\n\tprivate string _animationGizmoTarget = \"\";\n\tprivate RigControlKind _animationGizmoKind;\n\tprivate Transform _animationGizmoStartLocal;\n\tprivate Transform _animationGizmoStartWorld;\n\tprivate Transform? _animationGizmoStartParent;\n\tprivate Vector3 _animationGizmoMoveDelta;\n\tprivate Vector3 _animationGizmoScaleDelta;\n\tprivate RealTimeSince _sinceCameraSpeedChanged = 99;\n\n\tpublic ViewportPickMode PickMode { get; set; }\n\n\t/// <summary>\n\t/// Which custom anchor a <see cref=\"ViewportPickMode.CustomAnchor\"/> pick will place.\n\t/// </summary>\n\tpublic Guid PickAnchorId { get; set; }\n\tpublic bool IsPlaying => _controller.IsPlaying;\n\tpublic WeaponAnimatorTransformMode TransformMode { get; private set; }\n\tpublic Vector3 ModelDimensions => _sourceRenderer?.Model?.Bounds.Size ?? Vector3.Zero;\n\tpublic bool ConsumesFreeLookMovementShortcut =>\n\t\t_controller.Document.Workspace.FreeLookCamera\n\t\t&& !_controller.Document.Workspace.FirstPersonPreview\n\t\t&& IsActiveWindow\n\t\t&& IsUnderMouse\n\t\t&& PickMode == ViewportPickMode.None;\n\tpublic event Action<string>? StatusChanged;\n\tpublic event Action<Vector3>? ModelDimensionsChanged;\n\tpublic event Action? LegacyIdleRepaired;\n\n\tpublic WeaponAnimatorViewport(\n\t\tWeaponAnimatorController controller,\n\t\tWidget? parent = null ) : base( parent )\n\t{\n\t\t_controller = controller;\n\t\tMinimumSize = new Vector2( 420, 280 );\n\t\tFocusMode = FocusMode.Click;\n\t\tMouseTracking = true;\n\t\tScene = Scene.CreateEditorScene();\n\n\t\tusing ( Scene.Push() )\n\t\t{\n\t\t\t_camera = new GameObject( true, \"weapon_animator_camera\" )\n\t\t\t\t.GetOrAddComponent<CameraComponent>( false );\n\t\t\t_camera.BackgroundColor = WeaponAnimatorTheme.Background;\n\t\t\t_camera.ZNear = 0.5f;\n\t\t\t_camera.ZFar = 8192;\n\t\t\t_camera.Enabled = true;\n\t\t\tCamera = _camera;\n\n\t\t\tvar ambient = new GameObject( true, \"ambient\" )\n\t\t\t\t.GetOrAddComponent<AmbientLight>( false );\n\t\t\tambient.Color = new Color( 0.26f, 0.29f, 0.33f );\n\t\t\tambient.Enabled = true;\n\n\t\t\tvar key = new GameObject( true, \"key_light\" )\n\t\t\t\t.GetOrAddComponent<DirectionalLight>( false );\n\t\t\tkey.WorldRotation = Rotation.From( 38, 135, 0 );\n\t\t\tkey.LightColor = new Color( 1.0f, 0.92f, 0.82f ) * 1.3f;\n\t\t\tkey.SkyColor = new Color( 0.18f, 0.22f, 0.27f );\n\t\t\tkey.Enabled = true;\n\n\t\t\t_rimLight = new GameObject( true, \"rim_light\" )\n\t\t\t\t.GetOrAddComponent<PointLight>( false );\n\t\t\t_rimLight.WorldPosition = new Vector3( -32, 38, 28 );\n\t\t\t_rimLight.Radius = 160;\n\t\t}\n\t\t_flatArmsMaterial = Material.Load( \"materials/dev/primary_white.vmat\" );\n\t\tApplyViewportRenderStyle();\n\n\t\t_moveModeButton = AddTransformModeButton(\n\t\t\t\"open_with\",\n\t\t\t\"Move (W)\",\n\t\t\tWeaponAnimatorTransformMode.Move,\n\t\t\tnew Vector2( 10, 10 ) );\n\t\t_rotateModeButton = AddTransformModeButton(\n\t\t\t\"360\",\n\t\t\t\"Rotate (E)\",\n\t\t\tWeaponAnimatorTransformMode.Rotate,\n\t\t\tnew Vector2( 41, 10 ) );\n\t\t_scaleModeButton = AddTransformModeButton(\n\t\t\t\"zoom_out_map\",\n\t\t\t\"Scale (R)\",\n\t\t\tWeaponAnimatorTransformMode.Scale,\n\t\t\tnew Vector2( 72, 10 ) );\n\t\t_spaceButton = new WeaponAnimatorButton( \"\", \"public\", this )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tClicked = ToggleTransformSpace,\n\t\t\tPosition = new Vector2( 119, 10 ),\n\t\t\tFixedWidth = 28,\n\t\t\tFixedHeight = 28\n\t\t};\n\t\t_spaceButton.Raise();\n\t\t_rotationSnapButton = new WeaponAnimatorButton(\n\t\t\t\"\",\n\t\t\t\"rotate_90_degrees_cw\",\n\t\t\tthis )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tClicked = ToggleRotationSnap,\n\t\t\tPosition = new Vector2( 166, 10 ),\n\t\t\tFixedWidth = 28,\n\t\t\tFixedHeight = 28,\n\t\t\tToolTip = \"Toggle rotation snapping\"\n\t\t};\n\t\t_rotationSnapButton.Raise();\n\t\t_rotationSnapStep = new RotationSnapStepWidget(\n\t\t\t() => _controller.Document.Workspace.RotationSnapDegrees,\n\t\t\tSetRotationSnapDegrees,\n\t\t\tthis )\n\t\t{\n\t\t\tPosition = new Vector2( 197, 10 )\n\t\t};\n\t\t_rotationSnapStep.Raise();\n\t\t_orbitCameraButton = AddViewportActionButton(\n\t\t\t\"360\",\n\t\t\t\"Orbit camera\",\n\t\t\t() => SetCameraMode( false ) );\n\t\t_freeLookCameraButton = AddViewportActionButton(\n\t\t\t\"videocam\",\n\t\t\t\"Free look camera \u2014 RMB look, WASD move, wheel changes speed, Shift moves faster\",\n\t\t\t() => SetCameraMode( true ) );\n\t\t_lightingButton = AddViewportActionButton(\n\t\t\t\"light_mode\",\n\t\t\t\"Toggle lit / full bright\",\n\t\t\tToggleViewportLighting );\n\t\tPositionViewportActions();\n\n\t\t_controller.DocumentChanged += OnDocumentChanged;\n\t\t_controller.PoseChanged += Update;\n\t\t_controller.SelectionChanged += OnSelectionChanged;\n\t\t_controller.TimelineChanged += Update;\n\t\tRefreshTransformOverlay();\n\t\tRefreshViewportCameraButtons();\n\t\tRebuildPreview();\n\t}\n\n\tprotected override void OnResize()\n\t{\n\t\tbase.OnResize();\n\t\tPositionViewportActions();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\tEndCalibrationGizmoDrag();\n\t\tEndAnimationGizmoDrag();\n\t\t_controller.DocumentChanged -= OnDocumentChanged;\n\t\t_controller.PoseChanged -= Update;\n\t\t_controller.SelectionChanged -= OnSelectionChanged;\n\t\t_controller.TimelineChanged -= Update;\n\t\tReleasePreviewScene();\n\t\tbase.OnDestroyed();\n\t}\n\n\tpublic void ReleasePreviewScene()\n\t{\n\t\tif ( Scene.IsValid() )\n\t\t\tScene.Destroy();\n\t\tScene = null;\n\t\t_sourceRenderer = null;\n\t\t_armsRenderer = null;\n\t\t_armsHitboxes = null;\n\t\t_hostRenderer = null;\n\t\t_hostSkeleton = null;\n\t\t_occlusionCacheValid = false;\n\t}\n\n\tpublic void TogglePlayback()\n\t{\n\t\t_controller.TogglePlayback();\n\t}\n\n\tpublic void StopPlayback()\n\t{\n\t\t_controller.PausePlayback();\n\t}\n\n\tpublic void SetTransformMode( WeaponAnimatorTransformMode mode )\n\t{\n\t\tif ( TransformMode == mode )\n\t\t{\n\t\t\tRefreshTransformOverlay();\n\t\t\treturn;\n\t\t}\n\n\t\tEndCalibrationGizmoDrag();\n\t\tEndAnimationGizmoDrag();\n\t\tTransformMode = mode;\n\t\tRefreshTransformOverlay();\n\t\tStatusChanged?.Invoke( $\"{TransformModeName( mode )} gizmo selected.\" );\n\t\tUpdate();\n\t}\n\n\tprivate WeaponAnimatorButton AddTransformModeButton(\n\t\tstring icon,\n\t\tstring tooltip,\n\t\tWeaponAnimatorTransformMode mode,\n\t\tVector2 position )\n\t{\n\t\tvar button = new WeaponAnimatorButton( \"\", icon, this )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tClicked = () => SetTransformMode( mode ),\n\t\t\tPosition = position,\n\t\t\tFixedWidth = 28,\n\t\t\tFixedHeight = 28,\n\t\t\tToolTip = tooltip\n\t\t};\n\t\tbutton.Raise();\n\t\treturn button;\n\t}\n\n\tprivate WeaponAnimatorButton AddViewportActionButton(\n\t\tstring icon,\n\t\tstring tooltip,\n\t\tAction clicked )\n\t{\n\t\tvar button = new WeaponAnimatorButton( \"\", icon, this )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tClicked = clicked,\n\t\t\tFixedWidth = 28,\n\t\t\tFixedHeight = 28,\n\t\t\tToolTip = tooltip\n\t\t};\n\t\tbutton.Raise();\n\t\treturn button;\n\t}\n\n\tprivate void PositionViewportActions()\n\t{\n\t\tif ( _lightingButton is null )\n\t\t\treturn;\n\n\t\tvar right = MathF.Max( Width - 10, 113 );\n\t\t_lightingButton.Position = new Vector2( right - 28, 10 );\n\t\t_freeLookCameraButton.Position = new Vector2( right - 72, 10 );\n\t\t_orbitCameraButton.Position = new Vector2( right - 103, 10 );\n\t}\n\n\tprivate void SetCameraMode( bool freeLook )\n\t{\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tif ( workspace.FreeLookCamera == freeLook )\n\t\t{\n\t\t\tRefreshViewportCameraButtons();\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.UpdateWorkspacePreference(\n\t\t\tfreeLook ? \"Free look camera\" : \"Orbit camera\",\n\t\t\tstate =>\n\t\t\t{\n\t\t\t\tstate.FirstPersonPreview = false;\n\t\t\t\tif ( freeLook )\n\t\t\t\t{\n\t\t\t\t\tstate.CameraPosition = _camera.WorldPosition;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tvar rotation = Rotation.From( state.CameraAngles );\n\t\t\t\t\tstate.CameraFocus = state.CameraPosition\n\t\t\t\t\t\t+ rotation.Forward * state.CameraDistance;\n\t\t\t\t}\n\t\t\t\tstate.FreeLookCamera = freeLook;\n\t\t\t} );\n\t\tRefreshViewportCameraButtons();\n\t\tUpdateCamera();\n\t}\n\n\tprivate void ToggleViewportLighting()\n\t{\n\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\"Viewport lighting\",\n\t\t\tstate => state.FullBrightViewport = !state.FullBrightViewport );\n\t\tRefreshViewportCameraButtons();\n\t\tUpdateCamera();\n\t}\n\n\tprivate void RefreshViewportCameraButtons()\n\t{\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tRefreshTransformModeButton(\n\t\t\t_orbitCameraButton,\n\t\t\t!workspace.FreeLookCamera );\n\t\tRefreshTransformModeButton(\n\t\t\t_freeLookCameraButton,\n\t\t\tworkspace.FreeLookCamera );\n\t\t_lightingButton.IsChecked = workspace.FullBrightViewport;\n\t\t_lightingButton.Tint = workspace.FullBrightViewport\n\t\t\t? WeaponAnimatorTheme.Amber * 0.55f\n\t\t\t: WeaponAnimatorTheme.SurfaceRaised;\n\t\t_lightingButton.ToolTip = workspace.FullBrightViewport\n\t\t\t? \"Full bright \u2014 click for Lit\"\n\t\t\t: \"Lit \u2014 click for Full bright\";\n\t}\n\n\tprivate void ToggleTransformSpace()\n\t{\n\t\t_controller.Mutate(\n\t\t\t\"Transform coordinate space\",\n\t\t\tdocument => document.Workspace.LocalGizmos =\n\t\t\t\t!document.Workspace.LocalGizmos );\n\t\tRefreshTransformOverlay();\n\t}\n\n\tprivate void ToggleRotationSnap()\n\t{\n\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\"Rotation snapping\",\n\t\t\tstate => state.SnapRotation = !state.SnapRotation );\n\t\tRefreshTransformOverlay();\n\t\tUpdate();\n\t}\n\n\tprivate void SetRotationSnapDegrees( float value )\n\t{\n\t\tif ( !WeaponAnimationMath.IsFinite( value ) )\n\t\t\treturn;\n\n\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\"Rotation snap angle\",\n\t\t\tstate => state.RotationSnapDegrees = Math.Clamp( value, 0.25f, 180.0f ) );\n\t\tRefreshTransformOverlay();\n\t\tUpdate();\n\t}\n\n\tprivate void RefreshTransformOverlay()\n\t{\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tvar local = workspace.LocalGizmos;\n\t\tRefreshTransformModeButton(\n\t\t\t_moveModeButton,\n\t\t\tTransformMode == WeaponAnimatorTransformMode.Move );\n\t\tRefreshTransformModeButton(\n\t\t\t_rotateModeButton,\n\t\t\tTransformMode == WeaponAnimatorTransformMode.Rotate );\n\t\tRefreshTransformModeButton(\n\t\t\t_scaleModeButton,\n\t\t\tTransformMode == WeaponAnimatorTransformMode.Scale );\n\t\t_spaceButton.IsChecked = !local;\n\t\t_spaceButton.Tint = local\n\t\t\t? WeaponAnimatorTheme.SurfaceRaised\n\t\t\t: WeaponAnimatorTheme.Cyan * 0.55f;\n\t\t_spaceButton.ToolTip = local\n\t\t\t? \"Local space \u2014 click for World\"\n\t\t\t: \"World space \u2014 click for Local\";\n\t\tRefreshTransformModeButton(\n\t\t\t_rotationSnapButton,\n\t\t\tworkspace.SnapRotation );\n\t\t_rotationSnapStep.Refresh();\n\t\tGizmoInstance.Settings.SnapToAngles = workspace.SnapRotation;\n\t\tGizmoInstance.Settings.AngleSpacing =\n\t\t\tWeaponAnimationMath.IsFinite( workspace.RotationSnapDegrees )\n\t\t\t\t? Math.Clamp( workspace.RotationSnapDegrees, 0.25f, 180.0f )\n\t\t\t\t: 15.0f;\n\t\t_transformModeText =\n\t\t\t$\"{TransformModeName( TransformMode ).ToUpperInvariant()} \u00b7 {(local ? \"LOCAL\" : \"WORLD\")}\";\n\t}\n\n\tprivate static void RefreshTransformModeButton(\n\t\tWeaponAnimatorButton button,\n\t\tbool selected )\n\t{\n\t\tbutton.IsChecked = selected;\n\t\tbutton.Tint = selected\n\t\t\t? WeaponAnimatorTheme.Cyan * 0.55f\n\t\t\t: WeaponAnimatorTheme.SurfaceRaised;\n\t}\n\n\tprivate static string TransformModeName( WeaponAnimatorTransformMode mode ) =>\n\t\tmode switch\n\t\t{\n\t\t\tWeaponAnimatorTransformMode.Rotate => \"Rotate\",\n\t\t\tWeaponAnimatorTransformMode.Scale => \"Scale\",\n\t\t\t_ => \"Move\"\n\t\t};\n\n\tpublic void SetPickMode( ViewportPickMode mode, Guid anchorId = default )\n\t{\n\t\tPickMode = mode;\n\t\tPickAnchorId = anchorId;\n\t\tStatusChanged?.Invoke( mode == ViewportPickMode.None\n\t\t\t? \"Pick mode cleared.\"\n\t\t\t: $\"Click the weapon surface or a bone to set {PickLabel( mode )}.\" );\n\t}\n\n\tpublic void FitCamera()\n\t{\n\t\tvar bounds = _sourceRenderer?.Bounds ?? _hostRenderer?.Bounds;\n\t\tif ( bounds is null )\n\t\t\treturn;\n\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tworkspace.CameraFocus = bounds.Value.Center;\n\t\tworkspace.CameraDistance =\n\t\t\tMathF.Max( bounds.Value.Size.Length * 1.25f, 12 );\n\t\tif ( workspace.FreeLookCamera )\n\t\t{\n\t\t\tvar rotation = Rotation.From( workspace.CameraAngles );\n\t\t\tworkspace.CameraPosition = workspace.CameraFocus\n\t\t\t\t- rotation.Forward * workspace.CameraDistance;\n\t\t}\n\t\tUpdateCamera();\n\t}\n\n\tpublic void RebuildPreview()\n\t{\n\t\tif ( !Scene.IsValid() )\n\t\t\treturn;\n\n\t\t_occlusionCacheValid = false;\n\t\tusing ( Scene.Push() )\n\t\t{\n\t\t\t_sourceRenderer?.GameObject.Destroy();\n\t\t\t_armsRenderer?.GameObject.Destroy();\n\t\t\t_hostRenderer?.GameObject.Destroy();\n\t\t\t_sourceRenderer = null;\n\t\t\t_armsRenderer = null;\n\t\t\t_armsHitboxes = null;\n\t\t\t_hostRenderer = null;\n\t\t\t_hostSkeleton = null;\n\t\t\t_loadedSource = \"\";\n\t\t\t_loadedHost = \"\";\n\t\t\t_sourcePoseDiagnosticsLogged = false;\n\t\t\t_sourcePoseDiagnosticFrames = 0;\n\t\t\t_armPoseDiagnosticsLogged = false;\n\t\t\t_armPoseDiagnosticFrames = 0;\n\n\t\t\tvar document = _controller.Document;\n\t\t\tif ( !string.IsNullOrWhiteSpace( document.Source.CompiledModelPath ) )\n\t\t\t{\n\t\t\t\t// Remember failed loads too. Retrying a full scene rebuild every frame creates\n\t\t\t\t// overlapping renderers while the scene processes deferred destruction.\n\t\t\t\t_loadedSource = document.Source.CompiledModelPath;\n\t\t\t\tvar sourceModel = Model.Load( document.Source.CompiledModelPath );\n\t\t\t\tif ( sourceModel is not null && !sourceModel.IsError )\n\t\t\t\t{\n\t\t\t\t\tvar sourceObject = new GameObject( true, \"source_weapon_preview\" );\n\t\t\t\t\t_sourceRenderer = sourceObject.GetOrAddComponent<SkinnedModelRenderer>( false );\n\t\t\t\t\t_sourceRenderer.Model = sourceModel;\n\t\t\t\t\t_sourceRenderer.Enabled = true;\n\t\t\t\t\tModelDimensionsChanged?.Invoke( sourceModel.Bounds.Size );\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tLog.Warning(\n\t\t\t\t\t\t$\"[Weapon Animator] source preview model is unavailable: \"\n\t\t\t\t\t\t+ $\"'{document.Source.CompiledModelPath}'. \"\n\t\t\t\t\t\t+ \"The viewport will wait for a path change or a manual rebuild.\" );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar armsModel = Model.Load( HostSkeletonBuilder.ProductionArmsModel );\n\t\t\tif ( armsModel is null || armsModel.IsError )\n\t\t\t\tarmsModel = HostSkeletonBuilder.LoadArmProfile();\n\t\t\tif ( armsModel is not null && !armsModel.IsError )\n\t\t\t{\n\t\t\t\tvar armsObject = new GameObject( true, \"facepunch_arms_preview\" );\n\t\t\t\tarmsObject.Tags.Add( ArmsOccluderTag );\n\t\t\t\t_armsRenderer = armsObject.GetOrAddComponent<SkinnedModelRenderer>( false );\n\t\t\t\t_armsRenderer.Model = armsModel;\n\t\t\t\t_armsRenderer.Enabled = true;\n\t\t\t\t_armsRenderer.Tint = new Color( 0.42f, 0.84f, 0.92f, 0.42f );\n\t\t\t\t_armsHitboxes = armsObject.GetOrAddComponent<ModelHitboxes>( false );\n\t\t\t\t_armsHitboxes.Renderer = _armsRenderer;\n\t\t\t\t_armsHitboxes.Target = armsObject;\n\t\t\t\t_armsHitboxes.Enabled = true;\n\t\t\t}\n\n\t\t\tif ( document.ActiveStage == WeaponAnimatorStage.Animate\n\t\t\t\t&& !string.IsNullOrWhiteSpace( document.Source.PreviewHostPath ) )\n\t\t\t{\n\t\t\t\t// Failed host loads wait for a path change or an explicit rebuild.\n\t\t\t\t_loadedHost = document.Source.PreviewHostPath;\n\t\t\t\tvar hostModel = Model.Load( document.Source.PreviewHostPath );\n\t\t\t\tif ( hostModel is not null && !hostModel.IsError )\n\t\t\t\t{\n\t\t\t\t\t_hostRenderer = new GameObject( true, \"animation_host_preview\" )\n\t\t\t\t\t\t.GetOrAddComponent<SkinnedModelRenderer>( false );\n\t\t\t\t\t_hostRenderer.Model = hostModel;\n\t\t\t\t\t_hostRenderer.Enabled = true;\n\t\t\t\t\t_hostRenderer.UseAnimGraph = false;\n\t\t\t\t\tSuppressHostRendering();\n\t\t\t\t\t_hostSkeleton = HostSkeletonBuilder.BuildCached( document );\n\n\t\t\t\t\tif ( _sourceRenderer.IsValid() )\n\t\t\t\t\t{\n\t\t\t\t\t\t_sourceRenderer!.WorldTransform = Transform.Zero;\n\t\t\t\t\t\t_sourceRenderer.BoneMergeTarget = null;\n\t\t\t\t\t}\n\t\t\t\t\tif ( _armsRenderer.IsValid() )\n\t\t\t\t\t{\n\t\t\t\t\t\t_armsRenderer!.WorldTransform = Transform.Zero;\n\t\t\t\t\t\t_armsRenderer.BoneMergeTarget = null;\n\t\t\t\t\t\t_armsRenderer.Tint = Color.White;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tLog.Warning(\n\t\t\t\t\t\t$\"[Weapon Animator] animation host preview is unavailable: \"\n\t\t\t\t\t\t+ $\"'{document.Source.PreviewHostPath}'. \"\n\t\t\t\t\t\t+ \"The viewport will wait for a path change or a manual rebuild.\" );\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif ( _controller.Document.Workspace.CameraDistance <= 0 )\n\t\t\tFitCamera();\n\t\tUpdate();\n\t}\n\n\tprotected override void PreFrame()\n\t{\n\t\tScene.EditorTick( RealTime.Now, RealTime.Delta );\n\t\tGizmoInstance.Input.IsHovered = IsActiveWindow && IsUnderMouse;\n\t\tUpdateGizmoInputs( GizmoInstance.Input.IsHovered );\n\t\tFinishCalibrationGizmoDragIfReleased();\n\t\tFinishAnimationGizmoDragIfReleased();\n\n\t\tif ( RepairLegacyIdleIfNeeded() )\n\t\t\treturn;\n\t\tEnsurePreviewCurrent();\n\t\tAdvancePlayback();\n\t\tUpdateFreeLookMovement();\n\t\tUpdateCamera();\n\t\tApplyViewportRenderStyle();\n\n\t\tDrawWorkspaceGrid();\n\t\tif ( _controller.Document.ActiveStage == WeaponAnimatorStage.Calibrate )\n\t\t\tDrawCalibration();\n\t\telse\n\t\t\tDrawAnimation();\n\n\t\tDrawScreenGuides();\n\t\tDrawViewportToolReadout();\n\t\tDrawCameraSpeedOverlay();\n\t\tCursor = Gizmo.HasHovered || PickMode != ViewportPickMode.None\n\t\t\t? CursorShape.Finger\n\t\t\t: _controller.Document.Workspace.FreeLookCamera\n\t\t\t\t&& global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Right )\n\t\t\t\t&& IsUnderMouse\n\t\t\t\t\t? CursorShape.Blank\n\t\t\t\t\t: CursorShape.Arrow;\n\t}\n\n\tprivate void DrawWorkspaceGrid()\n\t{\n\t\tvar style = GridVisualStyle.Resolve(\n\t\t\t_controller.Document.Workspace.GridOpacity,\n\t\t\t_controller.Document.Workspace.GridLineThickness );\n\t\tif ( style.AxisOpacity <= 0 )\n\t\t\treturn;\n\n\t\tvar spacing = MathF.Max( Gizmo.Settings.GridSpacing, 1 );\n\t\tvar desiredExtent = MathF.Max(\n\t\t\t128,\n\t\t\t_controller.Document.Workspace.CameraDistance * 6 );\n\t\tvar halfLines = Math.Clamp(\n\t\t\t(int)MathF.Ceiling( desiredExtent / spacing ),\n\t\t\t8,\n\t\t\t64 );\n\t\tvar extent = halfLines * spacing;\n\n\t\tusing var scope = Gizmo.Scope( \"weapon_animator_grid\" );\n\t\tfor ( var index = -halfLines; index <= halfLines; index++ )\n\t\t{\n\t\t\tif ( index == 0 )\n\t\t\t\tcontinue;\n\n\t\t\tvar coordinate = index * spacing;\n\t\t\tvar major = index % 4 == 0;\n\t\t\tGizmo.Draw.Color = Color.White.WithAlpha(\n\t\t\t\tmajor ? style.MajorOpacity : style.MinorOpacity );\n\t\t\tGizmo.Draw.LineThickness = major ? style.MajorWidth : style.MinorWidth;\n\t\t\tGizmo.Draw.Line(\n\t\t\t\tnew Vector3( coordinate, -extent, 0 ),\n\t\t\t\tnew Vector3( coordinate, extent, 0 ) );\n\t\t\tGizmo.Draw.Line(\n\t\t\t\tnew Vector3( -extent, coordinate, 0 ),\n\t\t\t\tnew Vector3( extent, coordinate, 0 ) );\n\t\t}\n\n\t\tGizmo.Draw.LineThickness = style.AxisWidth;\n\t\tGizmo.Draw.Color = new Color( 0.90f, 0.28f, 0.38f ).WithAlpha( style.AxisOpacity );\n\t\tGizmo.Draw.Line( new Vector3( -extent, 0, 0 ), new Vector3( extent, 0, 0 ) );\n\t\tGizmo.Draw.Color = new Color( 0.58f, 0.78f, 0.20f ).WithAlpha( style.AxisOpacity );\n\t\tGizmo.Draw.Line( new Vector3( 0, -extent, 0 ), new Vector3( 0, extent, 0 ) );\n\t\tGizmo.Draw.LineThickness = 1;\n\t}\n\n\tprotected override void OnMouseMove( MouseEvent e )\n\t{\n\t\tbase.OnMouseMove( e );\n\t\tvar delta = e.LocalPosition - _lastMouse;\n\t\t_lastMouse = e.LocalPosition;\n\t\tif ( (e.ButtonState & MouseButtons.Right) == 0\n\t\t\t|| _controller.Document.Workspace.FirstPersonPreview )\n\t\t\treturn;\n\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tworkspace.CameraAngles = new Angles(\n\t\t\tMath.Clamp( workspace.CameraAngles.pitch + delta.y * 0.22f, -88, 88 ),\n\t\t\tworkspace.CameraAngles.yaw - delta.x * 0.22f,\n\t\t\t0 );\n\t\t_controller.MarkWorkspacePreferenceChanged( \"Viewport camera rotation\" );\n\t\tUpdateCamera();\n\t}\n\n\tprotected override void OnMousePress( MouseEvent e )\n\t{\n\t\tbase.OnMousePress( e );\n\t\t_lastMouse = e.LocalPosition;\n\t\tif ( !e.LeftMouseButton || PickMode == ViewportPickMode.None )\n\t\t\treturn;\n\n\t\tif ( TryPickSourceSurface( e.LocalPosition, out var localPosition ) )\n\t\t{\n\t\t\tApplyPickedPoint( localPosition );\n\t\t\te.Accepted = true;\n\t\t}\n\t}\n\n\tprotected override void OnMouseWheel( WheelEvent e )\n\t{\n\t\tif ( _controller.Document.Workspace.FirstPersonPreview )\n\t\t\treturn;\n\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tif ( workspace.FreeLookCamera )\n\t\t{\n\t\t\tvar direction = Math.Sign( e.Delta );\n\t\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\t\"Free look camera speed\",\n\t\t\t\tstate => state.CameraMoveSpeed = AdjustCameraSpeed(\n\t\t\t\t\tstate.CameraMoveSpeed,\n\t\t\t\t\tdirection ) );\n\t\t\t_sinceCameraSpeedChanged = 0;\n\t\t\te.Accept();\n\t\t\tUpdate();\n\t\t\treturn;\n\t\t}\n\n\t\tworkspace.CameraDistance = Math.Clamp(\n\t\t\tworkspace.CameraDistance * (e.Delta > 0 ? 0.9f : 1.1f),\n\t\t\t2,\n\t\t\t4096 );\n\t\t_controller.MarkWorkspacePreferenceChanged( \"Orbit camera distance\" );\n\t\te.Accept();\n\t}\n\n\tprivate void OnDocumentChanged()\n\t{\n\t\t_occlusionCacheValid = false;\n\t\tRefreshTransformOverlay();\n\t\tRefreshViewportCameraButtons();\n\t\tvar document = _controller.Document;\n\t\tif ( document.Source.CompiledModelPath != _loadedSource\n\t\t\t|| (document.ActiveStage == WeaponAnimatorStage.Animate\n\t\t\t\t&& document.Source.PreviewHostPath != _loadedHost)\n\t\t\t|| (document.ActiveStage == WeaponAnimatorStage.Calibrate && _hostRenderer.IsValid()) )\n\t\t{\n\t\t\tRebuildPreview();\n\t\t\treturn;\n\t\t}\n\n\t\tUpdate();\n\t}\n\n\tprivate void EnsurePreviewCurrent()\n\t{\n\t\tif ( !Scene.IsValid() )\n\t\t\treturn;\n\t\tvar requestedSource = _controller.Document.Source.CompiledModelPath;\n\t\tif ( _sourceRenderer is null\n\t\t\t&& ShouldRetryMissingSourcePreview( requestedSource, _loadedSource ) )\n\t\t\tRebuildPreview();\n\t}\n\n\tinternal static bool ShouldRetryMissingSourcePreview(\n\t\tstring requestedSource,\n\t\tstring attemptedSource ) =>\n\t\t!string.IsNullOrWhiteSpace( requestedSource )\n\t\t&& !requestedSource.Equals( attemptedSource, StringComparison.OrdinalIgnoreCase );\n\n\tprivate void AdvancePlayback()\n\t{\n\t\t_controller.AdvancePlayback( RealTime.Delta );\n\t}\n\n\tprivate void UpdateCamera()\n\t{\n\t\tif ( !_camera.IsValid() )\n\t\t\treturn;\n\n\t\tvar document = _controller.Document;\n\t\t_camera.DebugMode = document.Workspace.FullBrightViewport\n\t\t\t? SceneCameraDebugMode.FullBright\n\t\t\t: SceneCameraDebugMode.Normal;\n\t\tif ( document.Workspace.FirstPersonPreview )\n\t\t{\n\t\t\t_camera.WorldPosition = Vector3.Zero;\n\t\t\t_camera.WorldRotation = Rotation.Identity;\n\t\t\tvar aspect = GuideAspect( document.Calibration.AspectGuide );\n\t\t\tvar horizontalRadians = document.Calibration.HorizontalFov.DegreeToRadian();\n\t\t\t_camera.FieldOfView = (2.0f * MathF.Atan(\n\t\t\t\tMathF.Tan( horizontalRadians * 0.5f ) / aspect )).RadianToDegree();\n\t\t\treturn;\n\t\t}\n\n\t\tvar rotation = Rotation.From( document.Workspace.CameraAngles );\n\t\tif ( document.Workspace.FreeLookCamera )\n\t\t{\n\t\t\t_camera.WorldPosition = document.Workspace.CameraPosition;\n\t\t\t_camera.WorldRotation = rotation;\n\t\t\t_camera.FieldOfView = 48;\n\t\t\treturn;\n\t\t}\n\n\t\tvar focus = document.Workspace.CameraFocus;\n\t\t_camera.WorldPosition = focus - rotation.Forward * document.Workspace.CameraDistance;\n\t\t_camera.WorldRotation = Rotation.LookAt( focus - _camera.WorldPosition, Vector3.Up );\n\t\t_camera.FieldOfView = 48;\n\t}\n\n\tprivate void ApplyViewportRenderStyle()\n\t{\n\t\tvar document = _controller.Document;\n\t\tvar rim = ViewportRimLightStyle.Resolve(\n\t\t\tdocument.Workspace.RimLightEnabled,\n\t\t\tdocument.Workspace.RimLightIntensity,\n\t\t\tdocument.Workspace.FullBrightViewport );\n\t\t_rimLight.Enabled = rim.Enabled;\n\t\t_rimLight.LightColor = rim.Color;\n\n\t\tif ( !_armsRenderer.IsValid() )\n\t\t\treturn;\n\t\tvar arms = ArmPreviewVisualStyle.Resolve(\n\t\t\tdocument.ActiveStage,\n\t\t\tdocument.Workspace.FullBrightViewport );\n\t\t_armsRenderer!.MaterialOverride = arms.UseFlatMaterial\n\t\t\t? _flatArmsMaterial\n\t\t\t: null;\n\t\t_armsRenderer.Tint = arms.Tint;\n\t}\n\n\tprivate void UpdateFreeLookMovement()\n\t{\n\t\tvar workspace = _controller.Document.Workspace;\n\t\tif ( !workspace.FreeLookCamera\n\t\t\t|| workspace.FirstPersonPreview\n\t\t\t|| !IsActiveWindow\n\t\t\t|| !IsUnderMouse\n\t\t\t|| PickMode != ViewportPickMode.None\n\t\t\t|| Gizmo.Pressed.Any )\n\t\t\treturn;\n\n\t\tvar rotation = Rotation.From( workspace.CameraAngles );\n\t\tvar movement = Vector3.Zero;\n\t\tif ( global::Editor.Application.IsKeyDown( KeyCode.W ) )\n\t\t\tmovement += rotation.Forward;\n\t\tif ( global::Editor.Application.IsKeyDown( KeyCode.S ) )\n\t\t\tmovement += rotation.Backward;\n\t\tif ( global::Editor.Application.IsKeyDown( KeyCode.A ) )\n\t\t\tmovement += rotation.Left;\n\t\tif ( global::Editor.Application.IsKeyDown( KeyCode.D ) )\n\t\t\tmovement += rotation.Right;\n\t\tif ( movement.IsNearZeroLength )\n\t\t\treturn;\n\n\t\tvar fast = global::Editor.Application.KeyboardModifiers\n\t\t\t.HasFlag( KeyboardModifiers.Shift );\n\t\tvar speed = workspace.CameraMoveSpeed * 100.0f * (fast ? 8.0f : 1.0f);\n\t\tworkspace.CameraPosition += movement.Normal * speed * RealTime.Delta;\n\t\t_controller.MarkWorkspacePreferenceChanged( \"Free look camera position\" );\n\t}\n\n\tinternal static float AdjustCameraSpeed( float currentSpeed, int direction )\n\t{\n\t\tcurrentSpeed = Math.Clamp( currentSpeed, 0.25f, 100.0f );\n\t\tvar adjustment = currentSpeed < 5.0f\n\t\t\t? 0.25f\n\t\t\t: currentSpeed < 20.0f\n\t\t\t\t? 1.0f\n\t\t\t\t: MathF.Round( currentSpeed * 0.1f / 2.5f ) * 2.5f;\n\t\treturn Math.Clamp(\n\t\t\tcurrentSpeed + adjustment * Math.Sign( direction ),\n\t\t\t0.25f,\n\t\t\t100.0f );\n\t}\n\n\tinternal static float AdjustRotationSnapAngle( float currentAngle, int direction )\n\t{\n\t\tif ( !WeaponAnimationMath.IsFinite( currentAngle ) )\n\t\t\tcurrentAngle = 15;\n\n\t\tvar nearest = 0;\n\t\tvar nearestDistance = float.MaxValue;\n\t\tfor ( var index = 0; index < RotationSnapSteps.Length; index++ )\n\t\t{\n\t\t\tvar distance = MathF.Abs( currentAngle - RotationSnapSteps[index] );\n\t\t\tif ( distance >= nearestDistance )\n\t\t\t\tcontinue;\n\t\t\tnearest = index;\n\t\t\tnearestDistance = distance;\n\t\t}\n\n\t\tvar target = Math.Clamp(\n\t\t\tnearest + Math.Sign( direction ),\n\t\t\t0,\n\t\t\tRotationSnapSteps.Length - 1 );\n\t\treturn RotationSnapSteps[target];\n\t}\n\n\tprivate void DrawCalibration()\n\t{\n\t\tvar document = _controller.Document;\n\t\tif ( _sourceRenderer.IsValid() )\n\t\t{\n\t\t\t_sourceRenderer!.BoneMergeTarget = null;\n\t\t\t_sourceRenderer.WorldTransform = WeaponAnimationMath.Compose(\n\t\t\t\tdocument.Calibration.PhysicalTransform,\n\t\t\t\tdocument.Calibration.FramingTransform );\n\t\t\t_sourceRenderer.ClearPhysicsBones();\n\t\t}\n\n\t\tif ( _armsRenderer.IsValid() )\n\t\t{\n\t\t\t_armsRenderer!.BoneMergeTarget = null;\n\t\t\t_armsRenderer.WorldTransform = Transform.Zero;\n\t\t}\n\n\t\tDrawMeasurement();\n\t\tDrawAnchors();\n\t\tif ( document.Workspace.ShowSkeleton )\n\t\t\tDrawRendererSkeleton( _sourceRenderer, WeaponAnimatorTheme.Amber, allowXray: true );\n\n\t\t// Calibration only ever poses the weapon as a whole, plus its anchors. Selecting a bone\n\t\t// no longer suppresses the rig gizmo, which previously left the page with no gizmo at all.\n\t\tif ( CalibrationSelection.Resolve( document, document.Workspace.SelectedControl ) is { } anchor )\n\t\t\tDrawSelectedAnchorControl( anchor );\n\t\telse\n\t\t\tDrawWholeRigControl();\n\t}\n\n\tprivate void DrawAnimation()\n\t{\n\t\tif ( !_hostRenderer.IsValid() || _hostSkeleton is null )\n\t\t\treturn;\n\n\t\tvar document = _controller.Document;\n\t\tvar clip = document.GetSelectedClip();\n\t\tvar pose = AnimationPoseEvaluator.Evaluate(\n\t\t\tdocument,\n\t\t\t_hostSkeleton,\n\t\t\tclip,\n\t\t\tdocument.Workspace.TimelineTime,\n\t\t\tincludeWorkingPose: true );\n\n\t\t_hostRenderer!.ClearPhysicsBones();\n\t\tforeach ( var bone in _hostRenderer.Model.Bones.AllBones )\n\t\t{\n\t\t\tif ( pose.Model.TryGetValue( bone.Name, out var modelTransform ) )\n\t\t\t\t_hostRenderer.SetBoneTransform( bone, modelTransform );\n\t\t}\n\t\tSuppressHostRendering();\n\t\tApplyWeaponPoseToSourceRenderer( pose );\n\t\tApplyArmPoseToRenderer( pose );\n\n\t\tdocument.Binding.PrimaryHand.Reachable = pose.PrimaryReachable;\n\t\tdocument.Binding.SupportHand.Reachable = pose.SupportReachable;\n\t\tDrawGripTethers( pose );\n\t\tif ( document.Workspace.ShowSkeleton )\n\t\t\tDrawHostSkeleton( pose, 1.0f, useRenderedArms: true, allowXray: true );\n\t\tif ( document.Workspace.ShowOnionSkins && clip is not null )\n\t\t\tDrawOnionSkins( clip );\n\t\tDrawAnimationControl();\n\t}\n\n\tprivate void ApplyWeaponPoseToSourceRenderer( EvaluatedPose pose )\n\t{\n\t\tif ( !_sourceRenderer.IsValid() || _hostSkeleton is null )\n\t\t\treturn;\n\n\t\tvar document = _controller.Document;\n\t\tvar sourceRoot = document.Rig.FindBone( document.Rig.SourceSkeletonRootId );\n\t\tvar rootTransform = WeaponAnimationMath.Compose(\n\t\t\tdocument.Calibration.PhysicalTransform,\n\t\t\tdocument.Calibration.FramingTransform );\n\t\tif ( sourceRoot is not null\n\t\t\t&& pose.Model.TryGetValue( \"weapon_root\", out var desiredRootWorld ) )\n\t\t{\n\t\t\trootTransform = WeaponPoseProjection.SolveRendererTransform(\n\t\t\t\tsourceRoot.BindModelTransform,\n\t\t\t\tdesiredRootWorld );\n\t\t}\n\n\t\t_sourceRenderer!.BoneMergeTarget = null;\n\t\t_sourceRenderer.WorldTransform = rootTransform;\n\t\t_sourceRenderer.ClearPhysicsBones();\n\t\tforeach ( var definition in document.Rig.RetainedBones() )\n\t\t{\n\t\t\tif ( definition.Id.Equals(\n\t\t\t\tdocument.Rig.SourceSkeletonRootId,\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t\tcontinue;\n\n\t\t\tvar sourceBone = _sourceRenderer.Model.Bones.GetBone( definition.Name );\n\t\t\tif ( sourceBone is not null\n\t\t\t\t&& WeaponPoseProjection.TryGetSourceWorldOverride(\n\t\t\t\t\tdocument,\n\t\t\t\t\tpose,\n\t\t\t\t\tdefinition,\n\t\t\t\t\tout var transform )\n\t\t\t\t&& _hostSkeleton.ByName.TryGetValue( definition.Name, out var hostBone )\n\t\t\t\t&& pose.Local.TryGetValue( definition.Name, out var currentLocal )\n\t\t\t\t&& !WeaponPoseProjection.TransformNear(\n\t\t\t\t\tcurrentLocal,\n\t\t\t\t\t_hostSkeleton.GetBindLocal( hostBone ) ) )\n\t\t\t{\n\t\t\t\t// Native bind transforms remain untouched; only authored deltas use overrides.\n\t\t\t\t_sourceRenderer.SetBoneTransform(\n\t\t\t\t\tsourceBone,\n\t\t\t\t\t_sourceRenderer.WorldTransform.ToLocal( transform ) );\n\t\t\t}\n\t\t}\n\t\tApplyPreviewVisibility();\n\t\tLogSourcePoseDiagnostics( pose );\n\t}\n\n\tprivate void ApplyPreviewVisibility()\n\t{\n\t\tif ( !_sourceRenderer.IsValid() )\n\t\t\treturn;\n\n\t\tvar document = _controller.Document;\n\t\tvar clip = document.GetSelectedClip();\n\t\tforeach ( var part in document.Rig.VisibilityParts )\n\t\t{\n\t\t\tvar visible = WeaponVisibilityEvaluator.Evaluate(\n\t\t\t\tpart,\n\t\t\t\tclip,\n\t\t\t\tdocument.Workspace.TimelineTime );\n\t\t\tif ( part.RenderMode == VisibilityRenderMode.BodyGroup )\n\t\t\t{\n\t\t\t\tif ( string.IsNullOrWhiteSpace( part.BodyGroupName )\n\t\t\t\t\t|| !_sourceRenderer!.HasBodyGroups )\n\t\t\t\t\tcontinue;\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\t_sourceRenderer.SetBodyGroup(\n\t\t\t\t\t\tpart.BodyGroupName,\n\t\t\t\t\t\tvisible\n\t\t\t\t\t\t\t? part.VisibleBodyGroupValue\n\t\t\t\t\t\t\t: part.HiddenBodyGroupValue );\n\t\t\t\t}\n\t\t\t\tcatch ( Exception ex )\n\t\t\t\t{\n\t\t\t\t\tLog.Warning(\n\t\t\t\t\t\t$\"[Weapon Animator] preview bodygroup '{part.BodyGroupName}' failed: {ex.Message}\" );\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( !string.IsNullOrWhiteSpace( part.BodyGroupName )\n\t\t\t\t&& _sourceRenderer!.HasBodyGroups )\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\t_sourceRenderer.SetBodyGroup(\n\t\t\t\t\t\tpart.BodyGroupName,\n\t\t\t\t\t\tpart.VisibleBodyGroupValue );\n\t\t\t\t}\n\t\t\t\tcatch\n\t\t\t\t{\n\t\t\t\t\t// Switching back to bone mode should not leave the old bodygroup hidden.\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ( visible || string.IsNullOrWhiteSpace( part.BoneName ) )\n\t\t\t\tcontinue;\n\t\t\tvar root = _sourceRenderer!.Model.Bones.GetBone( part.BoneName );\n\t\t\tif ( root is null )\n\t\t\t\tcontinue;\n\n\t\t\tvar collapsed = new Transform(\n\t\t\t\tVector3.Down * 4000.0f,\n\t\t\t\tRotation.Identity,\n\t\t\t\tVector3.One * 0.001f );\n\t\t\tvar queue = new Queue<BoneCollection.Bone>();\n\t\t\tqueue.Enqueue( root );\n\t\t\twhile ( queue.Count > 0 )\n\t\t\t{\n\t\t\t\tvar bone = queue.Dequeue();\n\t\t\t\t_sourceRenderer.SetBoneTransform( bone, collapsed );\n\t\t\t\tforeach ( var child in bone.Children )\n\t\t\t\t\tqueue.Enqueue( child );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void ApplyArmPoseToRenderer( EvaluatedPose pose )\n\t{\n\t\tif ( !_armsRenderer.IsValid() )\n\t\t\treturn;\n\n\t\t_armsRenderer!.BoneMergeTarget = null;\n\t\t_armsRenderer.WorldTransform = Transform.Zero;\n\t\t_armsRenderer.ClearPhysicsBones();\n\t\tforeach ( var bone in _armsRenderer.Model.Bones.AllBones )\n\t\t{\n\t\t\tif ( pose.Model.TryGetValue( bone.Name, out var modelTransform ) )\n\t\t\t\t_armsRenderer.SetBoneTransform( bone, modelTransform );\n\t\t}\n\n\t\tLogArmPoseDiagnostics( pose );\n\t}\n\n\tprivate void LogArmPoseDiagnostics( EvaluatedPose pose )\n\t{\n\t\tif ( _armPoseDiagnosticsLogged || !_armsRenderer.IsValid() )\n\t\t\treturn;\n\t\tif ( ++_armPoseDiagnosticFrames < 3 )\n\t\t\treturn;\n\t\t_armPoseDiagnosticsLogged = true;\n\n\t\tvar compared = 0;\n\t\tvar mismatches = 0;\n\t\tforeach ( var bone in _armsRenderer!.Model.Bones.AllBones )\n\t\t{\n\t\t\tif ( !pose.Model.TryGetValue( bone.Name, out var expected )\n\t\t\t\t|| !_armsRenderer.TryGetBoneTransform( bone, out var actual ) )\n\t\t\t\tcontinue;\n\n\t\t\tcompared++;\n\t\t\tif ( WeaponPoseProjection.TransformNear( expected, actual, 0.001f ) )\n\t\t\t\tcontinue;\n\t\t\tmismatches++;\n\t\t\tif ( mismatches <= 4 )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\"[Weapon Animator] arm pose mismatch '{bone.Name}': \"\n\t\t\t\t\t+ $\"expected={expected}, actual={actual}.\" );\n\t\t\t}\n\t\t}\n\n\t\tLog.Info(\n\t\t\t$\"[Weapon Animator] arm pose bridge checked {compared} bones; \"\n\t\t\t+ $\"{mismatches} renderer override mismatches.\" );\n\t}\n\n\tprivate void LogSourcePoseDiagnostics( EvaluatedPose pose )\n\t{\n\t\tif ( _sourcePoseDiagnosticsLogged || !_sourceRenderer.IsValid() )\n\t\t\treturn;\n\t\tif ( ++_sourcePoseDiagnosticFrames < 3 )\n\t\t\treturn;\n\t\t_sourcePoseDiagnosticsLogged = true;\n\n\t\tvar compared = 0;\n\t\tvar mismatches = 0;\n\t\tvar hiddenVisibilityBones = HiddenVisibilityBonesAtPlayhead();\n\t\tforeach ( var definition in _controller.Document.Rig.RetainedBones() )\n\t\t{\n\t\t\tif ( hiddenVisibilityBones.Contains( definition.Name ) )\n\t\t\t\tcontinue;\n\t\t\tvar sourceBone = _sourceRenderer!.Model.Bones.GetBone( definition.Name );\n\t\t\tif ( sourceBone is null\n\t\t\t\t|| !WeaponPoseProjection.TryGetSourceWorldOverride(\n\t\t\t\t\t_controller.Document,\n\t\t\t\t\tpose,\n\t\t\t\t\tdefinition,\n\t\t\t\t\tout var expected )\n\t\t\t\t|| !_sourceRenderer.TryGetBoneTransform( sourceBone, out var actual ) )\n\t\t\t\tcontinue;\n\n\t\t\tcompared++;\n\t\t\tvar positionDelta = expected.Position.Distance( actual.Position );\n\t\t\tvar rotationDelta = MathF.Max(\n\t\t\t\t(expected.Rotation.Forward - actual.Rotation.Forward).Length,\n\t\t\t\t(expected.Rotation.Up - actual.Rotation.Up).Length );\n\t\t\tvar scaleDelta = (expected.Scale - actual.Scale).Length;\n\t\t\tif ( positionDelta <= 0.001f\n\t\t\t\t&& rotationDelta <= 0.001f\n\t\t\t\t&& scaleDelta <= 0.001f )\n\t\t\t\tcontinue;\n\n\t\t\tmismatches++;\n\t\t\tLog.Warning(\n\t\t\t\t$\"[Weapon Animator] source pose mismatch '{definition.Name}': \"\n\t\t\t\t+ $\"position={positionDelta:0.######}, \"\n\t\t\t\t+ $\"rotation={rotationDelta:0.######}, \"\n\t\t\t\t+ $\"scale={scaleDelta:0.######}; \"\n\t\t\t\t+ $\"expected={expected}, actual={actual}.\" );\n\t\t}\n\n\t\tLog.Info(\n\t\t\t$\"[Weapon Animator] source pose bridge checked {compared} retained bones; \"\n\t\t\t+ $\"{mismatches} renderer override mismatches.\" );\n\t\tif ( _hostSkeleton is not null\n\t\t\t&& _hostSkeleton.ByName.TryGetValue( \"root\", out var hostRoot )\n\t\t\t&& _hostSkeleton.ByName.TryGetValue( \"weapon_root\", out var weaponRoot )\n\t\t\t&& pose.Model.TryGetValue( \"weapon_root\", out var rootWorld )\n\t\t\t&& pose.Local.TryGetValue( \"weapon_root\", out var rootLocal ) )\n\t\t{\n\t\t\tLog.Info(\n\t\t\t\t$\"[Weapon Animator] root bridge: hostRootBind={hostRoot.BindModelTransform}, \"\n\t\t\t\t+ $\"weaponRootBindModel={weaponRoot.BindModelTransform}, \"\n\t\t\t\t+ $\"weaponRootBindLocal={_hostSkeleton.GetBindLocal( weaponRoot )}, \"\n\t\t\t\t+ $\"poseRootWorld={rootWorld}, poseRootLocal={rootLocal}, \"\n\t\t\t\t+ $\"sourceRenderer={_sourceRenderer!.WorldTransform}.\" );\n\t\t}\n\t}\n\n\tprivate HashSet<string> HiddenVisibilityBonesAtPlayhead()\n\t{\n\t\tvar document = _controller.Document;\n\t\tvar clip = document.GetSelectedClip();\n\t\tvar hidden = document.Rig.VisibilityParts\n\t\t\t.Where( x =>\n\t\t\t\tx.RenderMode == VisibilityRenderMode.BoneBranch\n\t\t\t\t&& !WeaponVisibilityEvaluator.Evaluate(\n\t\t\t\t\tx,\n\t\t\t\t\tclip,\n\t\t\t\t\tdocument.Workspace.TimelineTime ) )\n\t\t\t.Select( x => x.BoneName )\n\t\t\t.Where( x => !string.IsNullOrWhiteSpace( x ) )\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tif ( hidden.Count == 0 )\n\t\t\treturn hidden;\n\n\t\tvar changed = true;\n\t\twhile ( changed )\n\t\t{\n\t\t\tchanged = false;\n\t\t\tforeach ( var bone in document.Rig.RetainedBones() )\n\t\t\t{\n\t\t\t\tif ( hidden.Contains( bone.Name )\n\t\t\t\t\t|| !hidden.Contains( bone.ParentName ) )\n\t\t\t\t\tcontinue;\n\t\t\t\thidden.Add( bone.Name );\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\t\treturn hidden;\n\t}\n\n\tprivate bool RepairLegacyIdleIfNeeded()\n\t{\n\t\tif ( _legacyIdleRepairVersionChecked == LegacyIdleRepairVersion\n\t\t\t|| _controller.Document.ActiveStage != WeaponAnimatorStage.Animate )\n\t\t\treturn false;\n\n\t\t_legacyIdleRepairVersionChecked = LegacyIdleRepairVersion;\n\t\tvar repaired = false;\n\t\t_controller.Mutate(\n\t\t\t\"Repair generated Idle bind pose\",\n\t\t\tdocument =>\n\t\t\t{\n\t\t\t\tvar repairedLegacy = WeaponAnimationMigration.RepairLegacyIdleBindPose(\n\t\t\t\t\tdocument,\n\t\t\t\t\t_hostSkeleton );\n\t\t\t\tvar repairedSelectionWrites = _hostSkeleton is not null\n\t\t\t\t\t&& IdleBindPoseService.RepairUnintendedSelectionWrites(\n\t\t\t\t\t\tdocument,\n\t\t\t\t\t\t_hostSkeleton );\n\t\t\t\trepaired = repairedLegacy || repairedSelectionWrites;\n\t\t\t} );\n\t\tif ( !repaired )\n\t\t\treturn false;\n\n\t\tLegacyIdleRepaired?.Invoke();\n\t\tStatusChanged?.Invoke(\n\t\t\t\"Restored the generated Idle clip to the current calibrated bind pose. \"\n\t\t\t+ \"A versioned backup will be created on save.\" );\n\t\treturn true;\n\t}\n\n\tprivate void SuppressHostRendering()\n\t{\n\t\tif ( !_hostRenderer.IsValid() )\n\t\t\treturn;\n\n\t\t// The host owns bones only. Its carrier mesh must never enter the authoring viewport.\n\t\t_hostRenderer!.Tint = Color.Transparent;\n\t\t_hostRenderer.SceneObject.RenderingEnabled = false;\n\t}\n\n\tprivate void OnSelectionChanged()\n\t{\n\t\t_occlusionCacheValid = false;\n\t\t_lastOcclusionDiagnostic = \"\";\n\t\tUpdate();\n\t\tif ( _controller.Document.ActiveStage != WeaponAnimatorStage.Animate )\n\t\t\treturn;\n\n\t\tvar selected = _controller.Document.Workspace.SelectedBone;\n\t\tif ( !selected.Equals( \"weapon_root\", StringComparison.OrdinalIgnoreCase )\n\t\t\t|| selected.Equals( _lastDiagnosticSelection, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn;\n\n\t\t_lastDiagnosticSelection = selected;\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tvar rootTrack = clip?.Tracks.FirstOrDefault( x =>\n\t\t\tx.Target.Equals( \"weapon_root\", StringComparison.OrdinalIgnoreCase ) );\n\t\tLog.Info(\n\t\t\t$\"[Weapon Animator] Preview diagnostic: selected=weapon_root, \"\n\t\t\t+ $\"sourceModel={_loadedSource}, hostModel={_loadedHost}, \"\n\t\t\t+ $\"sourceScale={_sourceRenderer?.WorldTransform.Scale}, \"\n\t\t\t+ $\"hostRendering={_hostRenderer?.SceneObject.RenderingEnabled}, \"\n\t\t\t+ $\"rootKeys={rootTrack?.Keys.Count ?? 0}, \"\n\t\t\t+ $\"workingOverride={_controller.Document.Workspace.GetWorkingPose(\n\t\t\t\tclip?.Id ?? Guid.Empty,\n\t\t\t\t\"weapon_root\" ) is not null}.\" );\n\t}\n\n\tprivate void DrawRendererSkeleton(\n\t\tSkinnedModelRenderer? renderer,\n\t\tColor color,\n\t\tbool allowXray = false )\n\t{\n\t\tif ( !renderer.IsValid() || renderer!.Model is null )\n\t\t\treturn;\n\n\t\tvar style = SkeletonOverlayStyle.Resolve(\n\t\t\tallowXray && _controller.Document.Workspace.XRaySkeleton,\n\t\t\t1.0f );\n\n\t\tusing ( Gizmo.Scope( \"source_skeleton\" ) )\n\t\t{\n\t\t\tGizmo.Draw.IgnoreDepth = style.DrawThroughMeshes;\n\t\t\tDrawRendererSkeletonPass( renderer, color, 1.0f );\n\t\t}\n\t}\n\n\tprivate void DrawRendererSkeletonPass(\n\t\tSkinnedModelRenderer renderer,\n\t\tColor color,\n\t\tfloat alpha )\n\t{\n\t\tforeach ( var bone in renderer.Model.Bones.AllBones )\n\t\t{\n\t\t\tif ( !renderer.TryGetBoneTransform( bone, out var transform ) )\n\t\t\t\tcontinue;\n\n\t\t\tif ( bone.Parent is not null\n\t\t\t\t&& renderer.TryGetBoneTransform( bone.Parent, out var parent ) )\n\t\t\t{\n\t\t\t\tGizmo.Draw.Color = color.WithAlpha( 0.55f * alpha );\n\t\t\t\tGizmo.Draw.Line( parent.Position, transform.Position );\n\t\t\t}\n\n\t\t\tusing var scope = Gizmo.Scope( $\"source_bone:{bone.Name}\", transform );\n\t\t\tvar selected = bone.Name == _controller.Document.Workspace.SelectedBone;\n\t\t\tvar radius = Math.Clamp( transform.Position.Distance( _camera.WorldPosition ) / 150.0f, 0.1f, 0.7f );\n\t\t\tGizmo.Draw.Color = (selected ? Color.White : color).WithAlpha( alpha );\n\t\t\tGizmo.Draw.SolidSphere(\n\t\t\t\tVector3.Zero,\n\t\t\t\tselected ? radius * 0.7f : radius * 0.35f,\n\t\t\t\t6,\n\t\t\t\t4 );\n\t\t\tGizmo.Hitbox.DepthBias = 0.01f;\n\t\t\tGizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, radius ) );\n\t\t\tif ( Gizmo.IsHovered )\n\t\t\t{\n\t\t\t\tGizmo.Draw.ScreenText( bone.Name, transform.Position, new Vector2( 10, -10 ) );\n\t\t\t\tif ( Gizmo.WasLeftMousePressed )\n\t\t\t\t\t_controller.SelectBone( bone.Name );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate void DrawHostSkeleton(\n\t\tEvaluatedPose pose,\n\t\tfloat alpha,\n\t\tbool useRenderedArms = false,\n\t\tbool allowXray = false )\n\t{\n\t\tif ( _hostSkeleton is null )\n\t\t\treturn;\n\n\t\tEnsureBoneDepths();\n\t\tvar style = SkeletonOverlayStyle.Resolve(\n\t\t\tallowXray && _controller.Document.Workspace.XRaySkeleton,\n\t\t\talpha );\n\n\t\tvar occludedBones = style.DrawThroughMeshes\n\t\t\t&& _controller.Document.Workspace.BoneOcclusionEnabled\n\t\t\t? ResolveOccludedBones( pose, useRenderedArms )\n\t\t\t: null;\n\t\tif ( occludedBones is { Count: > 0 } )\n\t\t{\n\t\t\tDrawMixedOcclusionLines(\n\t\t\t\tpose,\n\t\t\t\tuseRenderedArms,\n\t\t\t\toccludedBones,\n\t\t\t\tstyle );\n\t\t\tusing ( Gizmo.Scope( \"host_skeleton_behind\" ) )\n\t\t\t{\n\t\t\t\tGizmo.Draw.IgnoreDepth = true;\n\t\t\t\tGizmo.Draw.LineThickness = SkeletonOverlayStyle.OccludedLineThickness;\n\t\t\t\tDrawHostSkeletonPass(\n\t\t\t\t\tpose,\n\t\t\t\t\tstyle.OccludedAlpha,\n\t\t\t\t\tuseRenderedArms,\n\t\t\t\t\toccluded: true,\n\t\t\t\t\tonlyBones: occludedBones,\n\t\t\t\t\tocclusionStates: occludedBones );\n\t\t\t}\n\t\t}\n\n\t\tusing ( Gizmo.Scope( \"host_skeleton\" ) )\n\t\t{\n\t\t\tGizmo.Draw.IgnoreDepth = style.DrawThroughMeshes;\n\t\t\tDrawHostSkeletonPass(\n\t\t\t\tpose,\n\t\t\t\talpha,\n\t\t\t\tuseRenderedArms,\n\t\t\t\texcludedBones: occludedBones,\n\t\t\t\tocclusionStates: occludedBones );\n\t\t}\n\t}\n\n\tprivate IReadOnlySet<string> ResolveOccludedBones(\n\t\tEvaluatedPose pose,\n\t\tbool useRenderedArms )\n\t{\n\t\tvar samples = new List<(HostBone Bone, Transform Transform)>();\n\t\tvar showIk = _controller.Document.Workspace.ShowIkBones;\n\t\tforeach ( var bone in _hostSkeleton!.Bones )\n\t\t{\n\t\t\tif ((SkeletonBoneStyle.Classify( bone ) == SkeletonBoneKind.Ik && !showIk)\n\t\t\t\t|| !TryGetDisplayedBoneTransform(\n\t\t\t\t\tpose,\n\t\t\t\t\tbone,\n\t\t\t\t\tuseRenderedArms,\n\t\t\t\t\tout var transform ) )\n\t\t\t\tcontinue;\n\n\t\t\tsamples.Add( (bone, transform) );\n\t\t}\n\n\t\tvar cacheMatches = OcclusionCacheMatches( samples );\n\t\tif ( cacheMatches && !_occlusionFollowupPending )\n\t\t\treturn _occludedBones;\n\t\tif ( !cacheMatches\n\t\t\t&& _occlusionCacheValid\n\t\t\t&& _sinceOcclusionTrace < (1.0f / 30.0f) )\n\t\t\treturn _occludedBones;\n\n\t\tvar completingFollowup = cacheMatches && _occlusionFollowupPending;\n\t\t_occlusionPose.Clear();\n\t\t_occludedBones.Clear();\n\t\t_occlusionCameraTransform = _camera.WorldTransform;\n\t\t_sinceOcclusionTrace = 0;\n\t\tforeach ( var sample in samples )\n\t\t{\n\t\t\t_occlusionPose[sample.Bone.Name] = sample.Transform;\n\t\t\tvar occluded = IsBoneOccluded(\n\t\t\t\tsample.Bone,\n\t\t\t\tsample.Transform.Position,\n\t\t\t\tout var hitDescription );\n\t\t\tif ( occluded )\n\t\t\t\t_occludedBones.Add( sample.Bone.Name );\n\n\t\t\tif ( sample.Bone.Name.Equals(\n\t\t\t\t_controller.Document.Workspace.SelectedBone,\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t\tReportOcclusionDiagnostic(\n\t\t\t\t\tsample.Bone,\n\t\t\t\t\thitDescription,\n\t\t\t\t\toccluded );\n\t\t}\n\n\t\t_occlusionCacheValid = true;\n\t\t_occlusionFollowupPending = !completingFollowup;\n\t\treturn _occludedBones;\n\t}\n\n\tprivate bool OcclusionCacheMatches(\n\t\tIReadOnlyList<(HostBone Bone, Transform Transform)> samples )\n\t{\n\t\tif ( !_occlusionCacheValid\n\t\t\t|| samples.Count != _occlusionPose.Count\n\t\t\t|| !WeaponPoseProjection.TransformNear(\n\t\t\t\t_occlusionCameraTransform,\n\t\t\t\t_camera.WorldTransform,\n\t\t\t\t0.0005f ) )\n\t\t\treturn false;\n\n\t\tforeach ( var sample in samples )\n\t\t{\n\t\t\tif ( !_occlusionPose.TryGetValue( sample.Bone.Name, out var cached )\n\t\t\t\t|| !WeaponPoseProjection.TransformNear(\n\t\t\t\t\tcached,\n\t\t\t\t\tsample.Transform,\n\t\t\t\t\t0.0005f ) )\n\t\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\tprivate bool IsBoneOccluded(\n\t\tHostBone target,\n\t\tVector3 targetPosition,\n\t\tout string description )\n\t{\n\t\tdescription = \"none\";\n\t\tif ( !Scene.IsValid()\n\t\t\t|| targetPosition.Distance( _camera.WorldPosition ) <= 0.001f )\n\t\t\treturn false;\n\n\t\tvar targetDistance = targetPosition.Distance( _camera.WorldPosition );\n\t\tvar depthClearance = SkeletonOverlayStyle.OcclusionDepthClearance( targetDistance );\n\t\tvar nearestDistance = float.MaxValue;\n\t\tvar sameArmHits = 0;\n\t\tvar oppositeArmHits = 0;\n\t\tvar nearArmHits = 0;\n\t\tvar unknownArmHits = 0;\n\t\tvar sameArmExample = \"\";\n\t\tvar unknownArmExample = \"\";\n\n\t\tvar armTraces = Scene.Trace\n\t\t\t.Ray( _camera.WorldPosition, targetPosition )\n\t\t\t.WithTag( ArmsOccluderTag )\n\t\t\t.UseRenderMeshes( false )\n\t\t\t.UseHitboxes( true )\n\t\t\t.UsePhysicsWorld( false )\n\t\t\t.UseHitPosition( true )\n\t\t\t.RunAll();\n\t\tforeach ( var armTrace in armTraces )\n\t\t{\n\t\t\tvar hitBoneName = ResolveArmHitBoneName( armTrace );\n\t\t\tvar hitSide = !string.IsNullOrWhiteSpace( hitBoneName )\n\t\t\t\t&& _hostSkeleton!.ByName.TryGetValue( hitBoneName, out var hitBone )\n\t\t\t\t\t? hitBone.ArmSide\n\t\t\t\t\t: 0;\n\t\t\tif ( !SkeletonOcclusionPolicy.IsOccludedByArm(\n\t\t\t\ttarget.IsWeaponBone,\n\t\t\t\ttarget.ArmSide,\n\t\t\t\thitSide ) )\n\t\t\t{\n\t\t\t\tif ( hitSide == target.ArmSide && hitSide != 0 )\n\t\t\t\t{\n\t\t\t\t\tsameArmHits++;\n\t\t\t\t\tif ( string.IsNullOrWhiteSpace( sameArmExample ) )\n\t\t\t\t\t\tsameArmExample = hitBoneName;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tunknownArmHits++;\n\t\t\t\t\tif ( string.IsNullOrWhiteSpace( unknownArmExample ) )\n\t\t\t\t\t\tunknownArmExample = hitBoneName;\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar gap = targetDistance - armTrace.Distance;\n\t\t\tif ( !SkeletonOverlayStyle.IsOccludingDepth(\n\t\t\t\ttargetDistance,\n\t\t\t\tarmTrace.Distance ) )\n\t\t\t{\n\t\t\t\tnearArmHits++;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\toppositeArmHits++;\n\t\t\tif ( armTrace.Distance >= nearestDistance )\n\t\t\t\tcontinue;\n\n\t\t\tnearestDistance = armTrace.Distance;\n\t\t\tdescription = string.IsNullOrWhiteSpace( hitBoneName )\n\t\t\t\t? $\"arms hitbox (unknown bone) at {armTrace.Distance:0.###}\"\n\t\t\t\t: $\"arms hitbox ({hitBoneName}) at {armTrace.Distance:0.###}\";\n\t\t}\n\n\t\tdescription +=\n\t\t\t$\"; armHits=opposite:{oppositeArmHits},self:{sameArmHits}\"\n\t\t\t+ $\"({sameArmExample}),near:{nearArmHits},unknown:{unknownArmHits}\"\n\t\t\t+ $\"({unknownArmExample}); target={targetDistance:0.###},\"\n\t\t\t+ $\"clearance={depthClearance:0.###},weaponIgnored=True\";\n\t\treturn nearestDistance < float.MaxValue;\n\t}\n\n\tprivate string ResolveArmHitBoneName( SceneTraceResult trace )\n\t{\n\t\tvar hitboxBoneName = trace.Hitbox?.Bone?.Name;\n\t\tif ( !string.IsNullOrWhiteSpace( hitboxBoneName ) )\n\t\t\treturn hitboxBoneName;\n\t\tif ( trace.Bone >= 0 && _armsRenderer.IsValid() )\n\t\t\treturn _armsRenderer!.Model.GetBoneName( trace.Bone );\n\t\treturn \"\";\n\t}\n\n\tprivate void ReportOcclusionDiagnostic(\n\t\tHostBone target,\n\t\tstring hitDescription,\n\t\tbool occluded )\n\t{\n\t\tvar diagnostic = $\"{target.Name}|{target.ArmSide}|{occluded}\";\n\t\tif ( diagnostic.Equals( _lastOcclusionDiagnostic, StringComparison.Ordinal ) )\n\t\t\treturn;\n\n\t\t_lastOcclusionDiagnostic = diagnostic;\n\t\tLog.Info(\n\t\t\t$\"[Weapon Animator] X-ray diagnostic: target={target.Name}, \"\n\t\t\t+ $\"targetSide={target.ArmSide}, firstHit={hitDescription}, \"\n\t\t\t+ $\"reduced={occluded}.\" );\n\t}\n\n\t/// <summary>\n\t/// Bone depth drives the overlay gradient. <c>HostSkeleton.Bones</c> is topologically ordered,\n\t/// so one forward pass resolves every depth. <c>BuildCached</c> hands out a shared read-only\n\t/// instance, so the cache is keyed on that instance rather than storing depth on the bones.\n\t/// </summary>\n\tprivate void EnsureBoneDepths()\n\t{\n\t\tif ( ReferenceEquals( _boneDepthSource, _hostSkeleton ) )\n\t\t\treturn;\n\n\t\t_boneDepthSource = _hostSkeleton;\n\t\t_boneDepths.Clear();\n\t\t_maxBoneDepth = 0;\n\t\tif ( _hostSkeleton is null )\n\t\t\treturn;\n\n\t\tforeach ( var bone in _hostSkeleton.Bones )\n\t\t{\n\t\t\tvar depth = !string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t&& _boneDepths.TryGetValue( bone.ParentName, out var parentDepth )\n\t\t\t\t\t? parentDepth + 1\n\t\t\t\t\t: 0;\n\t\t\t_boneDepths[bone.Name] = depth;\n\t\t\tif ( depth > _maxBoneDepth\n\t\t\t\t&& SkeletonBoneStyle.Classify( bone ) == SkeletonBoneKind.Arm )\n\t\t\t\t_maxBoneDepth = depth;\n\t\t}\n\t}\n\n\tprivate void DrawHostSkeletonPass(\n\t\tEvaluatedPose pose,\n\t\tfloat alpha,\n\t\tbool useRenderedArms,\n\t\tbool occluded = false,\n\t\tIReadOnlySet<string>? onlyBones = null,\n\t\tIReadOnlySet<string>? excludedBones = null,\n\t\tIReadOnlySet<string>? occlusionStates = null )\n\t{\n\t\tvar showIk = _controller.Document.Workspace.ShowIkBones;\n\t\tforeach ( var bone in _hostSkeleton!.Bones )\n\t\t{\n\t\t\tif ( onlyBones is not null && !onlyBones.Contains( bone.Name ) )\n\t\t\t\tcontinue;\n\t\t\tif ( excludedBones is not null && excludedBones.Contains( bone.Name ) )\n\t\t\t\tcontinue;\n\n\t\t\tif ( !TryGetDisplayedBoneTransform(\n\t\t\t\tpose,\n\t\t\t\tbone,\n\t\t\t\tuseRenderedArms,\n\t\t\t\tout var transform ) )\n\t\t\t\tcontinue;\n\n\t\t\tvar boneStyle = SkeletonBoneStyle.Resolve(\n\t\t\t\tSkeletonBoneStyle.Classify( bone ),\n\t\t\t\t_boneDepths.GetValueOrDefault( bone.Name ),\n\t\t\t\t_maxBoneDepth,\n\t\t\t\tshowIk );\n\t\t\t// Skipping also drops the hitbox below, so hidden bones stop stealing clicks.\n\t\t\tif ( !boneStyle.Visible )\n\t\t\t\tcontinue;\n\n\t\t\tvar color = occluded\n\t\t\t\t? SkeletonOverlayStyle.Occlude( boneStyle.Color )\n\t\t\t\t: boneStyle.Color;\n\t\t\tvar boneAlpha = alpha * boneStyle.AlphaScale;\n\t\t\tvar dotScale = occluded ? SkeletonOverlayStyle.OccludedDotScale : 1.0f;\n\t\t\tif ( !string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t&& _hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone )\n\t\t\t\t&& TryGetDisplayedBoneTransform(\n\t\t\t\t\tpose,\n\t\t\t\t\tparentBone,\n\t\t\t\t\tuseRenderedArms,\n\t\t\t\t\tout var parent ) )\n\t\t\t{\n\t\t\t\tvar parentOccluded = occlusionStates?.Contains( parentBone.Name )\n\t\t\t\t\t?? occluded;\n\t\t\t\tif ( parentOccluded == occluded )\n\t\t\t\t{\n\t\t\t\t\tGizmo.Draw.Color = color.WithAlpha( 0.45f * boneAlpha );\n\t\t\t\t\tGizmo.Draw.Line( parent.Position, transform.Position );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tusing var scope = Gizmo.Scope( $\"host_bone:{bone.Name}\", transform );\n\t\t\tvar selected = bone.Name == _controller.Document.Workspace.SelectedBone;\n\t\t\tvar radius = Math.Clamp( transform.Position.Distance( _camera.WorldPosition ) / 180.0f, 0.08f, 0.45f )\n\t\t\t\t* boneStyle.RadiusScale;\n\t\t\tGizmo.Draw.Color = (selected ? Color.White : color).WithAlpha( boneAlpha );\n\t\t\tif ( boneStyle.Hollow )\n\t\t\t\tGizmo.Draw.LineSphere( 0, (selected ? radius * 0.7f : radius * 0.45f) * dotScale, 3 );\n\t\t\telse\n\t\t\t\tGizmo.Draw.SolidSphere( 0, (selected ? radius * 0.7f : radius * 0.3f) * dotScale, 5, 4 );\n\t\t\tGizmo.Hitbox.Sphere( new Sphere( 0, radius ) );\n\t\t\tif ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )\n\t\t\t\t_controller.SelectBone( bone.Name );\n\t\t}\n\t}\n\n\tprivate void DrawMixedOcclusionLines(\n\t\tEvaluatedPose pose,\n\t\tbool useRenderedArms,\n\t\tIReadOnlySet<string> occludedBones,\n\t\tSkeletonOverlayStyle overlay )\n\t{\n\t\tvar showIk = _controller.Document.Workspace.ShowIkBones;\n\t\tusing var scope = Gizmo.Scope( \"host_skeleton_occlusion_gradients\" );\n\t\tGizmo.Draw.IgnoreDepth = true;\n\t\tforeach ( var bone in _hostSkeleton!.Bones )\n\t\t{\n\t\t\tif ( string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t|| !_hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone ) )\n\t\t\t\tcontinue;\n\n\t\t\tvar boneOccluded = occludedBones.Contains( bone.Name );\n\t\t\tvar parentOccluded = occludedBones.Contains( parentBone.Name );\n\t\t\tif ( boneOccluded == parentOccluded )\n\t\t\t\tcontinue;\n\n\t\t\tvar boneStyle = SkeletonBoneStyle.Resolve(\n\t\t\t\tSkeletonBoneStyle.Classify( bone ),\n\t\t\t\t_boneDepths.GetValueOrDefault( bone.Name ),\n\t\t\t\t_maxBoneDepth,\n\t\t\t\tshowIk );\n\t\t\tif ( !boneStyle.Visible )\n\t\t\t\tcontinue;\n\n\t\t\tvar parentStyle = SkeletonBoneStyle.Resolve(\n\t\t\t\tSkeletonBoneStyle.Classify( parentBone ),\n\t\t\t\t_boneDepths.GetValueOrDefault( parentBone.Name ),\n\t\t\t\t_maxBoneDepth,\n\t\t\t\tshowIk );\n\t\t\tif ( !parentStyle.Visible\n\t\t\t\t|| !TryGetDisplayedBoneTransform(\n\t\t\t\t\tpose,\n\t\t\t\t\tbone,\n\t\t\t\t\tuseRenderedArms,\n\t\t\t\t\tout var boneTransform )\n\t\t\t\t|| !TryGetDisplayedBoneTransform(\n\t\t\t\t\tpose,\n\t\t\t\t\tparentBone,\n\t\t\t\t\tuseRenderedArms,\n\t\t\t\t\tout var parentTransform ) )\n\t\t\t\tcontinue;\n\n\t\t\tvar startVisual = overlay.ResolveLineVisual(\n\t\t\t\tparentStyle,\n\t\t\t\tparentOccluded );\n\t\t\tvar endVisual = overlay.ResolveLineVisual(\n\t\t\t\tboneStyle,\n\t\t\t\tboneOccluded );\n\t\t\tvar delta = boneTransform.Position - parentTransform.Position;\n\t\t\tfor ( var segment = 0;\n\t\t\t\tsegment < SkeletonOverlayStyle.OcclusionGradientSegments;\n\t\t\t\tsegment++ )\n\t\t\t{\n\t\t\t\tvar startFraction =\n\t\t\t\t\tsegment / (float)SkeletonOverlayStyle.OcclusionGradientSegments;\n\t\t\t\tvar endFraction =\n\t\t\t\t\t(segment + 1) / (float)SkeletonOverlayStyle.OcclusionGradientSegments;\n\t\t\t\tvar visual = SkeletonLineVisual.Lerp(\n\t\t\t\t\tstartVisual,\n\t\t\t\t\tendVisual,\n\t\t\t\t\t(startFraction + endFraction) * 0.5f );\n\t\t\t\tGizmo.Draw.Color = visual.Color;\n\t\t\t\tGizmo.Draw.LineThickness = visual.Thickness;\n\t\t\t\tGizmo.Draw.Line(\n\t\t\t\t\tparentTransform.Position + (delta * startFraction),\n\t\t\t\t\tparentTransform.Position + (delta * endFraction) );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate bool TryGetDisplayedBoneTransform(\n\t\tEvaluatedPose pose,\n\t\tHostBone bone,\n\t\tbool useRenderedArms,\n\t\tout Transform transform )\n\t{\n\t\tif ( useRenderedArms && !bone.IsWeaponBone && _armsRenderer.IsValid() )\n\t\t{\n\t\t\tvar rendererBone = _armsRenderer!.Model.Bones.GetBone( bone.Name );\n\t\t\tif ( rendererBone is not null\n\t\t\t\t&& _armsRenderer.TryGetBoneTransform( rendererBone, out transform ) )\n\t\t\t\treturn true;\n\t\t}\n\n\t\treturn pose.Model.TryGetValue( bone.Name, out transform );\n\t}\n\n\tprivate void DrawOnionSkins( WeaponAnimationClip clip )\n\t{\n\t\tif ( _hostSkeleton is null )\n\t\t\treturn;\n\t\tvar step = 1.0f / MathF.Max( clip.SampleRate, 1 );\n\n\t\t// Onion skins are context only. They share bone names with the live skeleton, so leaving\n\t\t// them interactive would put duplicate hit targets on neighbouring frames.\n\t\tusing var scope = Gizmo.Scope( \"onion_skins\" );\n\t\tGizmo.Hitbox.CanInteract = false;\n\t\tforeach ( var offset in new[] { -step, step } )\n\t\t{\n\t\t\tvar time = Math.Clamp(\n\t\t\t\t_controller.Document.Workspace.TimelineTime + offset,\n\t\t\t\t0,\n\t\t\t\tclip.Duration );\n\t\t\tvar onion = AnimationPoseEvaluator.Evaluate(\n\t\t\t\t_controller.Document,\n\t\t\t\t_hostSkeleton,\n\t\t\t\tclip,\n\t\t\t\ttime );\n\t\t\tDrawHostSkeleton( onion, 0.18f );\n\t\t}\n\t}\n\n\tprivate void DrawGripTethers( EvaluatedPose pose )\n\t{\n\t\tif ( _controller.Document.Binding.PrimaryHand.IsBound )\n\t\t{\n\t\t\tDrawTether(\n\t\t\t\t_controller.Document.Binding.PrimaryHand,\n\t\t\t\tpose,\n\t\t\t\tpose.PrimaryHandGoal,\n\t\t\t\t_controller.Document.Binding.PrimaryHand.Reachable,\n\t\t\t\t\"hand_R\" );\n\t\t}\n\t\tif ( _controller.Document.Binding.Configuration == GripConfiguration.TwoHanded )\n\t\t{\n\t\t\tif ( _controller.Document.Binding.SupportHand.IsBound )\n\t\t\t{\n\t\t\t\tDrawTether(\n\t\t\t\t\t_controller.Document.Binding.SupportHand,\n\t\t\t\t\tpose,\n\t\t\t\t\tpose.SupportHandGoal,\n\t\t\t\t\t_controller.Document.Binding.SupportHand.Reachable,\n\t\t\t\t\t\"hand_L\" );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static void DrawTether(\n\t\tRigTarget target,\n\t\tEvaluatedPose pose,\n\t\tTransform? solvedGoal,\n\t\tbool reachable,\n\t\tstring handBone )\n\t{\n\t\tif ( !pose.Model.TryGetValue( handBone, out var hand ) )\n\t\t\treturn;\n\n\t\t// Draw the goal the IK actually solved toward. The raw binding transform is the bind-time\n\t\t// value, so it drifts away from the hand as soon as a clip animates the control - which made\n\t\t// the tether read as \"far out of reach\" while the hand sat correctly on the weapon.\n\t\tTransform targetTransform;\n\t\tif ( solvedGoal is { } goal )\n\t\t{\n\t\t\ttargetTransform = goal;\n\t\t}\n\t\telse\n\t\t{\n\t\t\ttargetTransform = target.Transform;\n\t\t\tif ( !string.IsNullOrWhiteSpace( target.AttachedBone )\n\t\t\t\t&& pose.Model.TryGetValue( target.AttachedBone, out var attached ) )\n\t\t\t{\n\t\t\t\ttargetTransform = new Transform(\n\t\t\t\t\tattached.PointToWorld( target.Transform.Position ),\n\t\t\t\t\tattached.Rotation * target.Transform.Rotation );\n\t\t\t}\n\t\t}\n\n\t\t// Scoped so the colour and thickness do not leak into whatever draws next.\n\t\tusing var scope = Gizmo.Scope( \"grip_tether\" );\n\t\tGizmo.Draw.Color = reachable ? WeaponAnimatorTheme.Green : WeaponAnimatorTheme.Coral;\n\t\tGizmo.Draw.LineThickness = 2.5f;\n\t\tGizmo.Draw.Line( hand.Position, targetTransform.Position );\n\t\tGizmo.Draw.SolidSphere( targetTransform.Position, 0.18f, 8, 6 );\n\t}\n\n\tprivate void DrawSelectedAnchorControl( WeaponAnchor anchor )\n\t{\n\t\tif ( !_sourceRenderer.IsValid() )\n\t\t\treturn;\n\n\t\tvar sourceTransform = _sourceRenderer!.WorldTransform;\n\t\tvar liveWorld = new Transform(\n\t\t\tsourceTransform.PointToWorld( anchor.LocalPosition ),\n\t\t\tsourceTransform.Rotation * anchor.LocalRotation );\n\t\tvar token = $\"anchor:{anchor.Kind}\";\n\t\tvar dragging = IsCalibrationDrag( token );\n\t\tvar startWorld = dragging ? _calibrationGizmoStartWorld : liveWorld;\n\t\tvar startLocal = dragging\n\t\t\t? _calibrationGizmoStartLocal\n\t\t\t: new Transform( anchor.LocalPosition, anchor.LocalRotation );\n\t\tvar basis = CalibrationGizmoBasis( startWorld );\n\n\t\t// Scale is deliberately dropped from the scope. Feeding the gizmo a scaled transform\n\t\t// resizes its handles by the calibration scale, and feeding it a rotated one makes the\n\t\t// handles point along the rig's local axes while the result is applied in world space.\n\t\tusing var scope = Gizmo.Scope(\n\t\t\t$\"anchor_control:{anchor.Kind}\",\n\t\t\tnew Transform( startWorld.Position, basis ) );\n\t\tGizmo.Draw.Color = AnchorColor( anchor.Kind );\n\t\tGizmo.Draw.LineSphere( new Sphere( Vector3.Zero, 0.24f ) );\n\n\t\tif ( TransformMode == WeaponAnimatorTransformMode.Rotate )\n\t\t{\n\t\t\tif ( Gizmo.Control.Rotate( \"anchor_rotate\", Rotation.Identity, out var delta ) )\n\t\t\t{\n\t\t\t\tBeginCalibrationGizmoDrag(\n\t\t\t\t\ttoken,\n\t\t\t\t\t$\"Rotate {anchor.Name} anchor\",\n\t\t\t\t\tliveWorld,\n\t\t\t\t\tnew Transform( anchor.LocalPosition, anchor.LocalRotation ) );\n\t\t\t\t// Rotate reports the total rotation since the grab, so it applies to the start.\n\t\t\t\tvar snapped = SnapRotation( delta );\n\t\t\t\tvar rotation = _controller.Document.Workspace.LocalGizmos\n\t\t\t\t\t? (startLocal.Rotation * snapped).Normal\n\t\t\t\t\t: (sourceTransform.Rotation.Inverse\n\t\t\t\t\t\t* snapped\n\t\t\t\t\t\t* sourceTransform.Rotation\n\t\t\t\t\t\t* startLocal.Rotation).Normal;\n\t\t\t\t_controller.UpdateContinuousEdit( document =>\n\t\t\t\t{\n\t\t\t\t\tvar selected = document.Calibration.FindAnchor( anchor.Id );\n\t\t\t\t\tif ( selected is null )\n\t\t\t\t\t\treturn;\n\t\t\t\t\tselected.LocalRotation = rotation;\n\t\t\t\t\tdocument.Calibration.Confirmed = false;\n\t\t\t\t} );\n\t\t\t}\n\t\t}\n\t\telse if ( TransformMode == WeaponAnimatorTransformMode.Move\n\t\t\t&& Gizmo.Control.Position( \"anchor_move\", Vector3.Zero, out var moveDelta, basis ) )\n\t\t{\n\t\t\tBeginCalibrationGizmoDrag(\n\t\t\t\ttoken,\n\t\t\t\t$\"Move {anchor.Name} anchor\",\n\t\t\t\tliveWorld,\n\t\t\t\tnew Transform( anchor.LocalPosition, anchor.LocalRotation ) );\n\t\t\t_calibrationGizmoMoveDelta += moveDelta;\n\t\t\tvar world = SnapPositionDelta(\n\t\t\t\t_calibrationGizmoStartWorld.Position,\n\t\t\t\t_calibrationGizmoMoveDelta,\n\t\t\t\tbasis );\n\t\t\tvar local = sourceTransform.PointToLocal( world );\n\t\t\t_controller.UpdateContinuousEdit( document =>\n\t\t\t{\n\t\t\t\tvar selected = document.Calibration.FindAnchor( anchor.Id );\n\t\t\t\tif ( selected is null )\n\t\t\t\t\treturn;\n\t\t\t\tselected.LocalPosition = local;\n\t\t\t\tdocument.Calibration.Confirmed = false;\n\t\t\t} );\n\t\t}\n\t}\n\n\tprivate void DrawWholeRigControl()\n\t{\n\t\tvar document = _controller.Document;\n\t\tvar framing = document.Workspace.FirstPersonPreview;\n\t\tvar live = framing\n\t\t\t? document.Calibration.FramingTransform\n\t\t\t: document.Calibration.PhysicalTransform;\n\t\tvar token = framing ? \"rig:framing\" : \"rig:physical\";\n\t\tvar dragging = IsCalibrationDrag( token );\n\t\tvar start = dragging ? _calibrationGizmoStartWorld : live;\n\t\tvar basis = CalibrationGizmoBasis( start );\n\n\t\tusing var scope = Gizmo.Scope(\n\t\t\t\"whole_rig\",\n\t\t\tnew Transform( start.Position, basis ) );\n\t\tGizmo.Draw.Color = WeaponAnimatorTheme.Amber;\n\t\tGizmo.Draw.LineSphere( new Sphere( Vector3.Zero, 0.3f ) );\n\n\t\tif ( TransformMode == WeaponAnimatorTransformMode.Rotate )\n\t\t{\n\t\t\tif ( Gizmo.Control.Rotate( \"rig_rotate\", Rotation.Identity, out var delta ) )\n\t\t\t{\n\t\t\t\tBeginCalibrationGizmoDrag( token, \"Refine whole-rig rotation\", live, live );\n\t\t\t\tvar snapped = SnapRotation( delta );\n\t\t\t\tvar rotation = document.Workspace.LocalGizmos\n\t\t\t\t\t? (_calibrationGizmoStartWorld.Rotation * snapped).Normal\n\t\t\t\t\t: (snapped * _calibrationGizmoStartWorld.Rotation).Normal;\n\t\t\t\t_controller.UpdateContinuousEdit( d =>\n\t\t\t\t\tSetRigTransform( d, framing, target => target.WithRotation( rotation ) ) );\n\t\t\t}\n\t\t}\n\t\telse if ( TransformMode == WeaponAnimatorTransformMode.Move\n\t\t\t&& Gizmo.Control.Position( \"rig_move\", Vector3.Zero, out var moveDelta, basis ) )\n\t\t{\n\t\t\tBeginCalibrationGizmoDrag( token, \"Refine whole-rig position\", live, live );\n\t\t\t_calibrationGizmoMoveDelta += moveDelta;\n\t\t\tvar position = SnapPositionDelta(\n\t\t\t\t_calibrationGizmoStartWorld.Position,\n\t\t\t\t_calibrationGizmoMoveDelta,\n\t\t\t\tbasis );\n\t\t\t_controller.UpdateContinuousEdit( d =>\n\t\t\t\tSetRigTransform( d, framing, target => target.WithPosition( position ) ) );\n\t\t}\n\t\telse if ( TransformMode == WeaponAnimatorTransformMode.Scale\n\t\t\t&& Gizmo.Control.Scale( \"rig_scale\", Vector3.Zero, out var scaleDelta, basis ) )\n\t\t{\n\t\t\tBeginCalibrationGizmoDrag( token, \"Refine whole-rig scale\", live, live );\n\t\t\t_calibrationGizmoScaleDelta += scaleDelta / 0.01f;\n\t\t\t// Rig scale is uniform, so respond to whichever handle is being dragged rather than\n\t\t\t// only the X axis. The uniform centre handle reports all three equally.\n\t\t\tvar dominant = DominantAxis( _calibrationGizmoScaleDelta );\n\t\t\tvar factor = MathF.Max(\n\t\t\t\t1.0f + dominant * ScaleGizmoSensitivity,\n\t\t\t\t0.0001f );\n\t\t\tvar uniform = MathF.Max(\n\t\t\t\t_calibrationGizmoStartWorld.Scale.x * factor,\n\t\t\t\t0.0001f );\n\t\t\t_controller.UpdateContinuousEdit( d =>\n\t\t\t{\n\t\t\t\tSetRigTransform( d, framing, target => target.WithScale( uniform ) );\n\t\t\t\tif ( !framing )\n\t\t\t\t\td.Calibration.UniformScale = uniform;\n\t\t\t} );\n\t\t}\n\t}\n\n\tinternal static float DominantAxis( Vector3 value )\n\t{\n\t\tvar dominant = value.x;\n\t\tif ( MathF.Abs( value.y ) > MathF.Abs( dominant ) )\n\t\t\tdominant = value.y;\n\t\tif ( MathF.Abs( value.z ) > MathF.Abs( dominant ) )\n\t\t\tdominant = value.z;\n\t\treturn dominant;\n\t}\n\n\t// The whole rig and its anchors are authored in world space unless Local is toggled on.\n\tprivate Rotation CalibrationGizmoBasis( Transform start ) =>\n\t\t_controller.Document.Workspace.LocalGizmos\n\t\t\t? start.Rotation\n\t\t\t: Rotation.Identity;\n\n\tprivate static void SetRigTransform(\n\t\tWeaponAnimationDocument document,\n\t\tbool framing,\n\t\tFunc<Transform, Transform> edit )\n\t{\n\t\tif ( framing )\n\t\t{\n\t\t\tdocument.Calibration.FramingTransform =\n\t\t\t\tedit( document.Calibration.FramingTransform );\n\t\t\treturn;\n\t\t}\n\n\t\tdocument.Calibration.PhysicalTransform =\n\t\t\tedit( document.Calibration.PhysicalTransform );\n\t\tdocument.Calibration.Confirmed = false;\n\t}\n\n\tprivate bool IsCalibrationDrag( string target ) =>\n\t\t_calibrationGizmoTarget.Equals( target, StringComparison.Ordinal );\n\n\t// Calibration gizmos accumulate into one undo entry, matching the animation gizmos below.\n\tprivate void BeginCalibrationGizmoDrag(\n\t\tstring target,\n\t\tstring description,\n\t\tTransform startWorld,\n\t\tTransform startLocal )\n\t{\n\t\t// Reopen if an unrelated mutation closed our continuous edit mid-drag, otherwise\n\t\t// UpdateContinuousEdit silently discards the rest of the drag.\n\t\tif ( IsCalibrationDrag( target ) && _controller.IsContinuousEditActive )\n\t\t\treturn;\n\n\t\tEndCalibrationGizmoDrag();\n\t\t_calibrationGizmoTarget = target;\n\t\t_calibrationGizmoStartWorld = startWorld;\n\t\t_calibrationGizmoStartLocal = startLocal;\n\t\t_calibrationGizmoMoveDelta = Vector3.Zero;\n\t\t_calibrationGizmoScaleDelta = Vector3.Zero;\n\t\t_controller.BeginContinuousEdit( description );\n\t}\n\n\tprivate void FinishCalibrationGizmoDragIfReleased()\n\t{\n\t\tif ( string.IsNullOrEmpty( _calibrationGizmoTarget ) )\n\t\t\treturn;\n\n\t\t// Requires both signals. Gizmo.Pressed can read false between frames while the mouse is\n\t\t// still held, and ending on that alone splits one drag into an undo entry per frame.\n\t\tif ( Gizmo.Pressed.Any\n\t\t\t|| global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Left ) )\n\t\t\treturn;\n\n\t\tEndCalibrationGizmoDrag();\n\t}\n\n\tprivate void EndCalibrationGizmoDrag()\n\t{\n\t\tif ( string.IsNullOrEmpty( _calibrationGizmoTarget ) )\n\t\t\treturn;\n\n\t\t_calibrationGizmoTarget = \"\";\n\t\t_calibrationGizmoMoveDelta = Vector3.Zero;\n\t\t_calibrationGizmoScaleDelta = Vector3.Zero;\n\t\t_controller.EndContinuousEdit();\n\t}\n\n\tprivate void DrawAnimationControl()\n\t{\n\t\tvar context = SelectionTransformContext.Resolve( _controller );\n\t\tif ( context is null )\n\t\t\treturn;\n\n\t\tvar dragging = _animationGizmoTarget.Equals(\n\t\t\tcontext.Target,\n\t\t\tStringComparison.OrdinalIgnoreCase );\n\t\tvar startWorld = dragging ? _animationGizmoStartWorld : context.WorldTransform;\n\t\tvar basis = context.LocalSpace\n\t\t\t? startWorld.Rotation\n\t\t\t: Rotation.Identity;\n\t\tvar gizmoTransform = new Transform( startWorld.Position, basis );\n\t\tusing var scope = Gizmo.Scope( $\"animate:{context.Target}\", gizmoTransform );\n\t\tGizmo.Draw.Color = context.Kind == RigControlKind.Weapon\n\t\t\t? WeaponAnimatorTheme.Amber\n\t\t\t: WeaponAnimatorTheme.Cyan;\n\t\tGizmo.Draw.LineSphere( new Sphere( 0, 0.25f ) );\n\n\t\tif ( TransformMode == WeaponAnimatorTransformMode.Rotate )\n\t\t{\n\t\t\tif ( Gizmo.Control.Rotate( \"rotate\", Rotation.Identity, out var delta ) )\n\t\t\t{\n\t\t\t\tBeginAnimationGizmoDrag( context );\n\t\t\t\tvar snapped = SnapRotation( delta );\n\t\t\t\tvar local = _animationGizmoStartLocal;\n\t\t\t\tif ( context.LocalSpace )\n\t\t\t\t{\n\t\t\t\t\tlocal.Rotation = (_animationGizmoStartLocal.Rotation * snapped).Normal;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tvar editedWorld = _animationGizmoStartWorld.WithRotation(\n\t\t\t\t\t\t(snapped * _animationGizmoStartWorld.Rotation).Normal );\n\t\t\t\t\tlocal = WorldToLocal( editedWorld, _animationGizmoStartParent );\n\t\t\t\t}\n\t\t\t\t_controller.UpdateTransformEditContinuous(\n\t\t\t\t\tcontext.Target,\n\t\t\t\t\tcontext.Kind,\n\t\t\t\t\tlocal );\n\t\t\t}\n\t\t}\n\t\telse if ( TransformMode == WeaponAnimatorTransformMode.Move\n\t\t\t&& Gizmo.Control.Position( \"move\", Vector3.Zero, out var delta, basis ) )\n\t\t{\n\t\t\tBeginAnimationGizmoDrag( context );\n\t\t\t_animationGizmoMoveDelta += delta;\n\t\t\tvar position = SnapPositionDelta(\n\t\t\t\t_animationGizmoStartWorld.Position,\n\t\t\t\t_animationGizmoMoveDelta,\n\t\t\t\tbasis );\n\t\t\tvar editedWorld = _animationGizmoStartWorld.WithPosition( position );\n\t\t\tvar local = WorldToLocal( editedWorld, _animationGizmoStartParent );\n\t\t\t_controller.UpdateTransformEditContinuous(\n\t\t\t\tcontext.Target,\n\t\t\t\tcontext.Kind,\n\t\t\t\tlocal );\n\t\t}\n\t\telse if ( TransformMode == WeaponAnimatorTransformMode.Scale\n\t\t\t&& Gizmo.Control.Scale( \"scale\", Vector3.Zero, out var scaleDelta, basis ) )\n\t\t{\n\t\t\tBeginAnimationGizmoDrag( context );\n\t\t\t_animationGizmoScaleDelta += scaleDelta / 0.01f;\n\t\t\tvar local = ScaleFromStart(\n\t\t\t\t_animationGizmoStartLocal,\n\t\t\t\t_animationGizmoStartWorld,\n\t\t\t\t_animationGizmoStartParent,\n\t\t\t\tcontext.LocalSpace,\n\t\t\t\t_animationGizmoScaleDelta );\n\t\t\t_controller.UpdateTransformEditContinuous(\n\t\t\t\tcontext.Target,\n\t\t\t\tcontext.Kind,\n\t\t\t\tlocal );\n\t\t}\n\t}\n\n\tprivate void BeginAnimationGizmoDrag( SelectionTransformContext context )\n\t{\n\t\tif ( _animationGizmoTarget.Equals(\n\t\t\tcontext.Target,\n\t\t\tStringComparison.OrdinalIgnoreCase )\n\t\t\t&& _animationGizmoKind == context.Kind )\n\t\t\treturn;\n\n\t\tEndAnimationGizmoDrag();\n\t\t_animationGizmoTarget = context.Target;\n\t\t_animationGizmoKind = context.Kind;\n\t\t_animationGizmoStartLocal = context.LocalTransform;\n\t\t_animationGizmoStartWorld = context.WorldTransform;\n\t\t_animationGizmoStartParent = context.ParentTransform;\n\t\t_animationGizmoMoveDelta = Vector3.Zero;\n\t\t_animationGizmoScaleDelta = Vector3.Zero;\n\t\t_controller.BeginContinuousEdit(\n\t\t\t$\"{TransformModeName( TransformMode )} {context.Target}\" );\n\t}\n\n\tprivate void FinishAnimationGizmoDragIfReleased()\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( _animationGizmoTarget )\n\t\t\t|| Gizmo.Pressed.Any )\n\t\t\treturn;\n\n\t\tEndAnimationGizmoDrag();\n\t}\n\n\tprivate void EndAnimationGizmoDrag()\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( _animationGizmoTarget ) )\n\t\t\treturn;\n\n\t\t_animationGizmoTarget = \"\";\n\t\t_animationGizmoMoveDelta = Vector3.Zero;\n\t\t_animationGizmoScaleDelta = Vector3.Zero;\n\t\t_animationGizmoStartParent = null;\n\t\t_controller.EndContinuousEdit();\n\t}\n\n\tprivate Rotation SnapRotation( Rotation delta ) =>\n\t\t_controller.Document.Workspace.SnapRotation ? Gizmo.Snap( delta ) : delta;\n\n\tprivate Vector3 SnapPositionDelta( Vector3 start, Vector3 movement, Rotation localSpace )\n\t{\n\t\tif ( !_controller.Document.Workspace.SnapPosition )\n\t\t\treturn start + movement;\n\n\t\treturn Gizmo.Snap( start, movement, localSpace );\n\t}\n\n\tinternal static Transform WorldToLocal( Transform world, Transform? parent ) =>\n\t\tparent is null ? world : parent.Value.ToLocal( world );\n\n\tinternal static Transform ScaleFromStart(\n\t\tTransform startLocal,\n\t\tTransform startWorld,\n\t\tTransform? parent,\n\t\tbool localSpace,\n\t\tVector3 accumulatedDelta )\n\t{\n\t\tvar factor = ClampScale(\n\t\t\tVector3.One + accumulatedDelta * ScaleGizmoSensitivity );\n\t\tif ( localSpace )\n\t\t\treturn startLocal.WithScale(\n\t\t\t\tClampScale( startLocal.Scale * factor ) );\n\n\t\tvar editedWorld = startWorld.WithScale(\n\t\t\tClampScale( startWorld.Scale * factor ) );\n\t\treturn WorldToLocal( editedWorld, parent );\n\t}\n\n\tprivate static Vector3 ClampScale( Vector3 scale ) =>\n\t\tnew(\n\t\t\tMathF.Max( scale.x, 0.0001f ),\n\t\t\tMathF.Max( scale.y, 0.0001f ),\n\t\t\tMathF.Max( scale.z, 0.0001f ) );\n\n\tprivate void DrawMeasurement()\n\t{\n\t\tvar measurement = _controller.Document.Calibration.Measurement;\n\t\tif ( !measurement.HasFirstPoint )\n\t\t\treturn;\n\n\t\tvar transform = _sourceRenderer?.WorldTransform ?? Transform.Zero;\n\t\tvar a = transform.PointToWorld( measurement.FirstPoint );\n\t\tGizmo.Draw.Color = WeaponAnimatorTheme.Cyan;\n\t\tGizmo.Draw.SolidSphere( a, 0.15f, 8, 6 );\n\t\tif ( !measurement.HasSecondPoint )\n\t\t\treturn;\n\n\t\tvar b = transform.PointToWorld( measurement.SecondPoint );\n\t\tGizmo.Draw.SolidSphere( b, 0.15f, 8, 6 );\n\t\tGizmo.Draw.LineThickness = 2;\n\t\tGizmo.Draw.Line( a, b );\n\t\tGizmo.Draw.ScreenText(\n\t\t\t$\"{measurement.FirstPoint.Distance( measurement.SecondPoint ):0.###} source units\",\n\t\t\t(a + b) * 0.5f,\n\t\t\tnew Vector2( 8, -8 ) );\n\t}\n\n\tprivate void DrawAnchors()\n\t{\n\t\tvar transform = _sourceRenderer?.WorldTransform ?? Transform.Zero;\n\t\tforeach ( var anchor in _controller.Document.Calibration.Anchors )\n\t\t{\n\t\t\tvar world = transform.PointToWorld( anchor.LocalPosition );\n\t\t\tvar color = AnchorColor( anchor.Kind );\n\t\t\tvar markerScale = Math.Clamp( world.Distance( _camera.WorldPosition ) / 75.0f, 0.45f, 1.4f );\n\t\t\tvar labelOffset = AnchorLabelOffset( anchor.Kind );\n\t\t\tvar leaderEnd = world\n\t\t\t\t+ _camera.WorldRotation.Right * labelOffset.x * markerScale\n\t\t\t\t+ _camera.WorldRotation.Up * labelOffset.y * markerScale;\n\t\t\tGizmo.Draw.Color = color.WithAlpha( 0.75f );\n\t\t\tGizmo.Draw.LineThickness = 1.5f;\n\t\t\tGizmo.Draw.Line( world, leaderEnd );\n\t\t\tGizmo.Draw.ScreenText(\n\t\t\t\t$\"[{AnchorCode( anchor.Kind )}] {CalibrationSelection.DisplayName( anchor ).ToUpperInvariant()}\",\n\t\t\t\tleaderEnd,\n\t\t\t\tnew Vector2( 6, -6 ),\n\t\t\t\tsize: 11 );\n\t\t\tvar token = CalibrationSelection.Anchor( anchor );\n\t\t\tusing var scope = Gizmo.Scope(\n\t\t\t\t$\"anchor:{anchor.Id:N}\",\n\t\t\t\tnew Transform( world, transform.Rotation * anchor.LocalRotation ) );\n\t\t\tvar selected = _controller.Document.Workspace.SelectedControl == token;\n\t\t\tGizmo.Draw.Color = selected ? Color.White : color;\n\t\t\tGizmo.Draw.SolidSphere( Vector3.Zero, selected ? 0.24f : 0.18f, 8, 6 );\n\t\t\tGizmo.Hitbox.Sphere( new Sphere( Vector3.Zero, 0.32f ) );\n\t\t\tif ( Gizmo.IsHovered && Gizmo.WasLeftMousePressed )\n\t\t\t\t_controller.SelectControl( token );\n\t\t}\n\n\t\tvar rear = _controller.Document.Calibration.GetAnchor( AnchorKind.RearBore );\n\t\tvar front = _controller.Document.Calibration.GetAnchor( AnchorKind.FrontBore );\n\t\tif ( rear is null || front is null )\n\t\t\treturn;\n\t\tGizmo.Draw.Color = WeaponAnimatorTheme.Amber;\n\t\tGizmo.Draw.LineThickness = 2;\n\t\tGizmo.Draw.Arrow(\n\t\t\ttransform.PointToWorld( rear.LocalPosition ),\n\t\t\ttransform.PointToWorld( front.LocalPosition ),\n\t\t\t0.6f,\n\t\t\t0.25f );\n\t}\n\n\tprivate void DrawScreenGuides()\n\t{\n\t\tvar document = _controller.Document;\n\t\tif ( !document.Workspace.ShowGuides )\n\t\t\treturn;\n\n\t\tvar viewport = new Rect( 0, 0, Size.x, Size.y );\n\t\tvar guideAspect = GuideAspect( document.Calibration.AspectGuide );\n\t\tvar viewportAspect = Size.x / MathF.Max( Size.y, 1 );\n\t\tRect guide;\n\t\tif ( viewportAspect > guideAspect )\n\t\t{\n\t\t\tvar width = Size.y * guideAspect;\n\t\t\tguide = new Rect( (Size.x - width) * 0.5f, 0, width, Size.y );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tvar height = Size.x / guideAspect;\n\t\t\tguide = new Rect( 0, (Size.y - height) * 0.5f, Size.x, height );\n\t\t}\n\n\t\tGizmo.Draw.ScreenRect(\n\t\t\tviewport,\n\t\t\tColor.Transparent,\n\t\t\tborderColor: Color.White.WithAlpha( 0.05f ),\n\t\t\tborderSize: new Vector4( 1 ) );\n\t\tGizmo.Draw.ScreenRect(\n\t\t\tguide,\n\t\t\tColor.Transparent,\n\t\t\tborderColor: Color.White.WithAlpha( 0.35f ),\n\t\t\tborderSize: new Vector4( 1 ) );\n\n\t\tif ( document.Calibration.ShowSafeArea )\n\t\t{\n\t\t\tGizmo.Draw.ScreenRect(\n\t\t\t\tguide.Shrink( guide.Width * 0.05f, guide.Height * 0.05f ),\n\t\t\t\tColor.Transparent,\n\t\t\t\tborderColor: WeaponAnimatorTheme.Cyan.WithAlpha( 0.24f ),\n\t\t\t\tborderSize: new Vector4( 1 ) );\n\t\t}\n\n\t\tif ( document.Calibration.ShowCrosshair )\n\t\t{\n\t\t\tGizmo.Draw.Color = Color.White.WithAlpha( 0.65f );\n\t\t\tGizmo.Draw.ScreenText( \"+\", guide.Center, size: 19, flags: TextFlag.Center );\n\t\t}\n\n\t\tGizmo.Draw.Color = WeaponAnimatorTheme.Muted;\n\t\tvar mode = document.Workspace.FirstPersonPreview\n\t\t\t? \"VIEWMODEL CAMERA\"\n\t\t\t: document.Workspace.FreeLookCamera\n\t\t\t\t? \"FREE LOOK\"\n\t\t\t\t: \"ORBIT\";\n\t\tGizmo.Draw.ScreenText(\n\t\t\t$\"{mode} \u00b7 {document.Calibration.AspectGuide} \u00b7 {document.Calibration.HorizontalFov:0}\u00b0 HFOV\",\n\t\t\tnew Vector2( 12, 52 ),\n\t\t\tsize: 10 );\n\t}\n\n\tprivate void DrawViewportToolReadout()\n\t{\n\t\tGizmo.Draw.ScreenRect(\n\t\t\tnew Rect( 109, 15, 1, 18 ),\n\t\t\tColor.White.WithAlpha( 0.14f ) );\n\t\tGizmo.Draw.ScreenRect(\n\t\t\tnew Rect( 157, 15, 1, 18 ),\n\t\t\tColor.White.WithAlpha( 0.14f ) );\n\t\tGizmo.Draw.ScreenRect(\n\t\t\tnew Rect( MathF.Max( Width - 47, 66 ), 15, 1, 18 ),\n\t\t\tColor.White.WithAlpha( 0.14f ) );\n\t\tGizmo.Draw.ScreenRect(\n\t\t\tTransformReadoutRect,\n\t\t\tWeaponAnimatorTheme.Background.WithAlpha( 0.25f ) );\n\t\tvar text = new TextRendering.Scope\n\t\t{\n\t\t\tText = _transformModeText,\n\t\t\tTextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.78f ),\n\t\t\tFontSize = 10 * global::Editor.Application.DpiScale,\n\t\t\tFontName = \"Inter\",\n\t\t\tFontWeight = 500,\n\t\t\tLineHeight = 1\n\t\t};\n\t\tGizmo.Draw.ScreenText(\n\t\t\ttext,\n\t\t\tnew Vector2(\n\t\t\t\tTransformReadoutRect.Left + 6,\n\t\t\t\tTransformReadoutRect.Center.y ),\n\t\t\tTextFlag.LeftCenter );\n\t}\n\n\tprivate void DrawCameraSpeedOverlay()\n\t{\n\t\tif ( _sinceCameraSpeedChanged >= 1.8f )\n\t\t\treturn;\n\n\t\tvar elapsed = (float)_sinceCameraSpeedChanged;\n\t\tvar alpha = elapsed <= 0.9f\n\t\t\t? 1.0f\n\t\t\t: 1.0f - Math.Clamp( (elapsed - 0.9f) / 0.9f, 0, 1 );\n\t\tvar rect = new Rect(\n\t\t\tMathF.Max( (Width - 150) * 0.5f, 0 ),\n\t\t\tWidth >= 720 ? 10 : Width >= 620 ? 46 : 82,\n\t\t\t150,\n\t\t\t28 );\n\t\tGizmo.Draw.ScreenRect(\n\t\t\trect,\n\t\t\tWeaponAnimatorTheme.Background.WithAlpha( 0.55f * alpha ) );\n\t\tvar text = new TextRendering.Scope\n\t\t{\n\t\t\tText = $\"CAMERA SPEED  {_controller.Document.Workspace.CameraMoveSpeed:0.##}\u00d7\",\n\t\t\tTextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.9f * alpha ),\n\t\t\tFontSize = 10 * global::Editor.Application.DpiScale,\n\t\t\tFontName = \"Inter\",\n\t\t\tFontWeight = 500,\n\t\t\tLineHeight = 1\n\t\t};\n\t\tGizmo.Draw.ScreenText( text, rect.Center, TextFlag.Center );\n\t}\n\n\tprivate bool TryPickSourceSurface( Vector2 localPosition, out Vector3 modelPosition )\n\t{\n\t\tmodelPosition = default;\n\t\tif ( !_sourceRenderer.IsValid() || _sourceRenderer!.Model is null )\n\t\t\treturn false;\n\n\t\tvar ray = GetRay( localPosition );\n\t\tvar localRay = ray.ToLocal( _sourceRenderer.WorldTransform );\n\t\tvar trace = _sourceRenderer.Model.Trace.Ray( localRay, 8192 ).Run();\n\t\tif ( !trace.Hit )\n\t\t\treturn false;\n\n\t\tmodelPosition = trace.HitPosition;\n\t\treturn true;\n\t}\n\n\tprivate void ApplyPickedPoint( Vector3 localPosition )\n\t{\n\t\tvar mode = PickMode;\n\t\tvar anchorId = PickAnchorId;\n\t\tPickMode = ViewportPickMode.None;\n\t\tPickAnchorId = default;\n\t\tvar token = \"\";\n\t\t_controller.Mutate( $\"Set {PickLabel( mode )}\", document =>\n\t\t{\n\t\t\tvar measurement = document.Calibration.Measurement;\n\t\t\tswitch ( mode )\n\t\t\t{\n\t\t\t\tcase ViewportPickMode.MeasurementFirst:\n\t\t\t\t\tmeasurement.FirstPoint = localPosition;\n\t\t\t\t\tmeasurement.HasFirstPoint = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase ViewportPickMode.MeasurementSecond:\n\t\t\t\t\tmeasurement.SecondPoint = localPosition;\n\t\t\t\t\tmeasurement.HasSecondPoint = true;\n\t\t\t\t\tbreak;\n\t\t\t\tcase ViewportPickMode.CustomAnchor:\n\t\t\t\t\t// Placing an existing custom anchor must not disturb its stored attachment name.\n\t\t\t\t\tif ( document.Calibration.FindAnchor( anchorId ) is not { } custom )\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tcustom.BoneName = document.Workspace.SelectedBone;\n\t\t\t\t\tcustom.LocalPosition = localPosition;\n\t\t\t\t\tdocument.Calibration.Confirmed = false;\n\t\t\t\t\ttoken = CalibrationSelection.Anchor( custom );\n\t\t\t\t\tbreak;\n\t\t\t\tdefault:\n\t\t\t\t\tvar kind = PickAnchorKind( mode );\n\t\t\t\t\tdocument.Calibration.SetAnchor( new WeaponAnchor\n\t\t\t\t\t{\n\t\t\t\t\t\tKind = kind,\n\t\t\t\t\t\tName = CalibrationSelection.DisplayName( kind ),\n\t\t\t\t\t\tBoneName = document.Workspace.SelectedBone,\n\t\t\t\t\t\tLocalPosition = localPosition\n\t\t\t\t\t} );\n\t\t\t\t\tdocument.Calibration.Confirmed = false;\n\t\t\t\t\ttoken = CalibrationSelection.Anchor( kind );\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t} );\n\t\tif ( !string.IsNullOrEmpty( token ) )\n\t\t\t_controller.SelectControl( token );\n\t\tStatusChanged?.Invoke( $\"{PickLabel( mode )} set at {localPosition}.\" );\n\t}\n\n\tprivate static AnchorKind PickAnchorKind( ViewportPickMode mode ) => mode switch\n\t{\n\t\tViewportPickMode.GripAnchor => AnchorKind.Grip,\n\t\tViewportPickMode.RearBoreAnchor => AnchorKind.RearBore,\n\t\tViewportPickMode.FrontBoreAnchor => AnchorKind.FrontBore,\n\t\tViewportPickMode.MuzzleAnchor => AnchorKind.Muzzle,\n\t\tViewportPickMode.EjectAnchor => AnchorKind.Eject,\n\t\t_ => AnchorKind.Custom\n\t};\n\n\tprivate static string PickLabel( ViewportPickMode mode ) => mode switch\n\t{\n\t\tViewportPickMode.MeasurementFirst => \"measurement point A\",\n\t\tViewportPickMode.MeasurementSecond => \"measurement point B\",\n\t\tViewportPickMode.GripAnchor => \"primary grip\",\n\t\tViewportPickMode.RearBoreAnchor => \"alignment marker \u2014 rear\",\n\t\tViewportPickMode.FrontBoreAnchor => \"alignment marker \u2014 front\",\n\t\tViewportPickMode.MuzzleAnchor => \"muzzle\",\n\t\tViewportPickMode.EjectAnchor => \"eject\",\n\t\tViewportPickMode.CustomAnchor => \"custom anchor\",\n\t\t_ => \"point\"\n\t};\n\n\tprivate static Color AnchorColor( AnchorKind kind ) => kind switch\n\t{\n\t\tAnchorKind.Grip => WeaponAnimatorTheme.Cyan,\n\t\tAnchorKind.RearBore => new Color( 0.64f, 0.48f, 0.95f ),\n\t\tAnchorKind.FrontBore => WeaponAnimatorTheme.Amber,\n\t\tAnchorKind.Muzzle => WeaponAnimatorTheme.Coral,\n\t\tAnchorKind.Eject => WeaponAnimatorTheme.Green,\n\t\t_ => Color.White\n\t};\n\n\tprivate static Vector2 AnchorLabelOffset( AnchorKind kind ) => kind switch\n\t{\n\t\tAnchorKind.Grip => new Vector2( -1.8f, 1.1f ),\n\t\tAnchorKind.RearBore => new Vector2( 1.5f, 1.7f ),\n\t\tAnchorKind.FrontBore => new Vector2( 1.7f, 0.8f ),\n\t\tAnchorKind.Muzzle => new Vector2( 2.1f, -0.5f ),\n\t\tAnchorKind.Eject => new Vector2( -1.7f, 1.8f ),\n\t\t_ => new Vector2( 1.5f, 1.0f )\n\t};\n\n\tprivate static string AnchorCode( AnchorKind kind ) => kind switch\n\t{\n\t\tAnchorKind.Grip => \"G\",\n\t\tAnchorKind.RearBore => \"AR\",\n\t\tAnchorKind.FrontBore => \"AF\",\n\t\tAnchorKind.Muzzle => \"M\",\n\t\tAnchorKind.Eject => \"E\",\n\t\t_ => \"A\"\n\t};\n\n\tprivate static float GuideAspect( string guide ) => guide switch\n\t{\n\t\t\"4:3\" => 4.0f / 3.0f,\n\t\t\"21:9\" => 21.0f / 9.0f,\n\t\t_ => 16.0f / 9.0f\n\t};\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Code/Runtime/WeaponVisibilityEvaluator.cs",
            "FileName": "WeaponVisibilityEvaluator.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace SboxWeaponAnimator;\n\npublic readonly record struct WeaponVisibilitySpan(\n\tstring Name,\n\tfloat StartTime,\n\tfloat EndTime,\n\tbool Visible );\n\npublic static class WeaponVisibilityEvaluator\n{\n\tprivate const float TimeTolerance = 0.0001f;\n\n\tpublic static bool Evaluate(\n\t\tWeaponVisibilityPart part,\n\t\tWeaponAnimationClip? clip,\n\t\tfloat time )\n\t{\n\t\tvar track = clip?.VisibilityTracks.FirstOrDefault( x =>\n\t\t\tx.PartId == part.Id && !x.Muted );\n\t\tif ( track is null )\n\t\t\treturn part.DefaultVisible;\n\n\t\tvar result = part.DefaultVisible;\n\t\tforeach ( var key in track.Keys )\n\t\t{\n\t\t\tif ( key.Time > time + TimeTolerance )\n\t\t\t\tbreak;\n\t\t\tresult = key.Visible;\n\t\t}\n\t\treturn result;\n\t}\n\n\tpublic static VisibilityKey UpsertKey(\n\t\tVisibilityTrack track,\n\t\tfloat time,\n\t\tbool visible )\n\t{\n\t\tvar key = track.Keys.FirstOrDefault( x =>\n\t\t\tMathF.Abs( x.Time - time ) <= TimeTolerance );\n\t\tif ( key is null )\n\t\t{\n\t\t\tkey = new VisibilityKey { Time = time };\n\t\t\ttrack.Keys.Add( key );\n\t\t}\n\n\t\tkey.Visible = visible;\n\t\ttrack.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );\n\t\treturn key;\n\t}\n\n\tpublic static string VisibleTag( Guid partId ) =>\n\t\t$\"wepanim_part_{partId:N}_visible\";\n\n\tpublic static string HiddenTag( Guid partId ) =>\n\t\t$\"wepanim_part_{partId:N}_hidden\";\n\n\tpublic static IReadOnlyList<WeaponVisibilitySpan> BuildSpans(\n\t\tWeaponVisibilityPart part,\n\t\tWeaponAnimationClip clip )\n\t{\n\t\tvar duration = MathF.Max( clip.Duration, TimeTolerance );\n\t\tvar transitions = clip.VisibilityTracks\n\t\t\t.FirstOrDefault( x => x.PartId == part.Id && !x.Muted )?\n\t\t\t.Keys\n\t\t\t.Where( x => x.Time >= 0 && x.Time <= duration + TimeTolerance )\n\t\t\t.OrderBy( x => x.Time )\n\t\t\t.ToArray() ?? [];\n\t\tvar result = new List<WeaponVisibilitySpan>();\n\t\tvar state = part.DefaultVisible;\n\t\tvar start = 0.0f;\n\n\t\tforeach ( var key in transitions )\n\t\t{\n\t\t\tvar time = Math.Clamp( key.Time, 0, duration );\n\t\t\tif ( key.Visible == state )\n\t\t\t\tcontinue;\n\n\t\t\tif ( time > start + TimeTolerance )\n\t\t\t\tresult.Add( Span( part, start, time, state ) );\n\t\t\tstate = key.Visible;\n\t\t\tstart = time;\n\t\t}\n\n\t\tif ( start < duration - TimeTolerance )\n\t\t\tresult.Add( Span( part, start, duration, state ) );\n\t\telse if ( result.Count == 0 )\n\t\t\tresult.Add( Span( part, 0, duration, state ) );\n\n\t\treturn result;\n\t}\n\n\tprivate static WeaponVisibilitySpan Span(\n\t\tWeaponVisibilityPart part,\n\t\tfloat start,\n\t\tfloat end,\n\t\tbool visible ) => new(\n\t\t\tvisible ? VisibleTag( part.Id ) : HiddenTag( part.Id ),\n\t\t\tstart,\n\t\t\tend,\n\t\t\tvisible );\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Services/AnimGraphWriter.cs",
            "FileName": "AnimGraphWriter.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing System.Text;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic static class AnimGraphWriter\n{\n\tpublic const string Header =\n\t\t\"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} \" +\n\t\t\"format:animgraph2:version{0f7898b8-5471-45c4-9867-cd9c46bcfdb5} -->\";\n\n\tprivate sealed record StateSpec(\n\t\tstring Name,\n\t\tWeaponClipRole Role,\n\t\tbool Loop,\n\t\tList<string> Transitions,\n\t\tbool Start = false );\n\n\tpublic static string Write( WeaponAnimationDocument document, string hostModelPath )\n\t{\n\t\tvar idle = document.Clips.First( x => x.Role == WeaponClipRole.Idle );\n\t\tWeaponAnimationClip ClipFor( WeaponClipRole role )\n\t\t{\n\t\t\tvar clip = document.Clips.FirstOrDefault( x => x.Role == role );\n\t\t\treturn clip is not null && clip.Readiness != ClipReadiness.NotStarted\n\t\t\t\t? clip\n\t\t\t\t: idle;\n\t\t}\n\n\t\tvar states = BuildStates( document );\n\t\tvar nodes = new StringBuilder();\n\t\tvar x = -960.0f;\n\t\tforeach ( var state in states )\n\t\t{\n\t\t\tvar sequenceClip = ClipFor( state.Role );\n\t\t\tnodes.AppendLine( SequenceNode(\n\t\t\t\t$\"seq_{state.Name}\",\n\t\t\t\tWeaponAnimationNames.SequenceName( sequenceClip ),\n\t\t\t\tsequenceClip,\n\t\t\t\tdocument.Rig.VisibilityParts,\n\t\t\t\tstate.Loop,\n\t\t\t\tx,\n\t\t\t\t96 ) );\n\t\t\tx += 144;\n\t\t}\n\n\t\tnodes.AppendLine( StateMachineNode( states ) );\n\t\tnodes.AppendLine( RootNode() );\n\n\t\tvar parameters = string.Join( \"\\n\", ParameterDefinitions() );\n\t\tvar tags = string.Join( \"\\n\", StandardTags( document ) );\n\t\treturn $$\"\"\"\n\t\t\t{{Header}}\n\t\t\t// SboxWeaponAnimator generated graph. Node, state, parameter, and tag IDs are deterministic.\n\t\t\t{\n\t\t\t\t_class = \"CAnimationGraph\"\n\t\t\t\tm_nodeManager =\n\t\t\t\t{\n\t\t\t\t\t_class = \"CAnimNodeManager\"\n\t\t\t\t\tm_nodes =\n\t\t\t\t\t[\n\t\t\t{{nodes}}\t\t]\n\t\t\t\t}\n\t\t\t\tm_pParameterList =\n\t\t\t\t{\n\t\t\t\t\t_class = \"CAnimParameterList\"\n\t\t\t\t\tm_Parameters =\n\t\t\t\t\t[\n\t\t\t{{parameters}}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t\tm_pTagManager =\n\t\t\t\t{\n\t\t\t\t\t_class = \"CAnimTagManager\"\n\t\t\t\t\tm_tags =\n\t\t\t\t\t[\n\t\t\t{{tags}}\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t\tm_pMovementManager =\n\t\t\t\t{\n\t\t\t\t\t_class = \"CAnimMovementManager\"\n\t\t\t\t\tm_MotorList = { _class = \"CAnimMotorList\" m_motors = [ ] }\n\t\t\t\t\tm_MovementSettings =\n\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"CAnimMovementSettings\"\n\t\t\t\t\t\tm_bShouldCalculateSlope = false\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tm_pSettingsManager =\n\t\t\t\t{\n\t\t\t\t\t_class = \"CAnimGraphSettingsManager\"\n\t\t\t\t\tm_settingsGroups =\n\t\t\t\t\t[\n\t\t\t\t\t\t{ _class = \"CAnimGraphGeneralSettings\" m_iGridSnap = 16 },\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t\tm_pActivityValuesList = { _class = \"CActivityValueList\" m_activities = [ ] }\n\t\t\t\tm_previewModels = [ \"{{hostModelPath}}\", ]\n\t\t\t\tm_boneMergeModels =\n\t\t\t\t[\n\t\t\t\t\t{\n\t\t\t\t\t\tm_name = \"{{HostSkeletonBuilder.ProductionArmsModel}}\"\n\t\t\t\t\t\tm_bEnabled = true\n\t\t\t\t\t},\n\t\t\t\t]\n\t\t\t\tm_cameraSettings =\n\t\t\t\t{\n\t\t\t\t\tm_flFov = {{F( document.Calibration.HorizontalFov )}}\n\t\t\t\t\tm_sLockBoneName = \"camera\"\n\t\t\t\t\tm_bLockCamera = true\n\t\t\t\t\tm_bViewModelCamera = false\n\t\t\t\t}\n\t\t\t}\n\t\t\t\"\"\";\n\t}\n\n\tprivate static List<StateSpec> BuildStates( WeaponAnimationDocument document )\n\t{\n\t\tvar idleTransitions = new List<string>\n\t\t{\n\t\t\tTransition( [BoolCondition( \"b_attack_dry\", true )], \"FireDry\", 0.02f ),\n\t\t\tTransition( [BoolCondition( \"b_attack\", true )], \"Fire\", 0.02f ),\n\t\t\tTransition(\n\t\t\t\t[BoolCondition( \"b_reload\", true ), BoolCondition( \"b_empty\", true )],\n\t\t\t\t\"ReloadEmpty\",\n\t\t\t\t0.05f ),\n\t\t\tTransition( [BoolCondition( \"b_reload\", true )], \"Reload\", 0.05f ),\n\t\t\tTransition( [BoolCondition( \"b_deploy\", true )], \"Deploy\", 0.05f ),\n\t\t\tTransition( [BoolCondition( \"b_holster\", true )], \"Holster\", 0.05f ),\n\t\t\tTransition( [BoolCondition( \"b_inspect\", true )], \"Inspect\", 0.08f ),\n\t\t\tTransition( [BoolCondition( \"b_sprint\", true )], \"Sprint\", 0.08f ),\n\t\t\tTransition( [BoolCondition( \"b_jump\", true )], \"Jump\", 0.05f ),\n\t\t\tTransition( [BoolCondition( \"b_lower_weapon\", true )], \"Lower\", 0.08f ),\n\t\t\tTransition( [IntCondition( \"ironsights\", 1 )], \"Ironsights\", 0.08f ),\n\t\t\tTransition( [BoolCondition( \"b_grab\", true )], \"GrabStance\", 0.08f ),\n\t\t\tTransition( [IntCondition( \"grab_action\", 1 )], \"GrabGesture1\", 0.04f ),\n\t\t\tTransition( [IntCondition( \"grab_action\", 2 )], \"GrabGesture2\", 0.04f ),\n\t\t\tTransition( [IntCondition( \"grab_action\", 3 )], \"GrabGesture3\", 0.04f ),\n\t\t\tTransition( [IntCondition( \"grab_action\", 4 )], \"GrabGesture4\", 0.04f )\n\t\t};\n\n\t\tif ( document.Graph.ReloadProfile == ReloadProfile.Incremental )\n\t\t\tidleTransitions.Insert( 3, Transition( [BoolCondition( \"b_reloading\", true )], \"ReloadEnter\", 0.05f ) );\n\n\t\tvar finishedToIdle = new List<string> { Transition( [FinishedCondition()], \"Idle\", 0.08f ) };\n\t\tvar states = new List<StateSpec>\n\t\t{\n\t\t\tnew( \"Idle\", WeaponClipRole.Idle, true, idleTransitions, true ),\n\t\t\tnew( \"Deploy\", WeaponClipRole.Deploy, false, finishedToIdle ),\n\t\t\tnew( \"Fire\", WeaponClipRole.Fire, false, [\n\t\t\t\tTransition( [BoolCondition( \"b_attack\", true )], \"Fire\", 0.01f ),\n\t\t\t\tTransition( [FinishedCondition()], \"Idle\", 0.06f )\n\t\t\t] ),\n\t\t\tnew( \"FireDry\", WeaponClipRole.FireDry, false, finishedToIdle ),\n\t\t\tnew( \"Reload\", WeaponClipRole.Reload, false, finishedToIdle ),\n\t\t\tnew( \"ReloadEmpty\", WeaponClipRole.ReloadEmpty, false, finishedToIdle ),\n\t\t\tnew( \"Holster\", WeaponClipRole.Holster, false, [] ),\n\t\t\tnew( \"Inspect\", WeaponClipRole.Inspect, false, finishedToIdle ),\n\t\t\tnew( \"Sprint\", WeaponClipRole.Sprint, true, [\n\t\t\t\tTransition( [BoolCondition( \"b_sprint\", false )], \"Idle\", 0.08f, false )\n\t\t\t] ),\n\t\t\tnew( \"Jump\", WeaponClipRole.Jump, false, finishedToIdle ),\n\t\t\tnew( \"Lower\", WeaponClipRole.Lower, true, [\n\t\t\t\tTransition( [BoolCondition( \"b_lower_weapon\", false )], \"Idle\", 0.08f, false )\n\t\t\t] ),\n\t\t\tnew( \"Ironsights\", WeaponClipRole.Ironsights, true, [\n\t\t\t\tTransition( [IntCondition( \"ironsights\", 0 )], \"Idle\", 0.08f, false )\n\t\t\t] ),\n\t\t\tnew( \"GrabStance\", WeaponClipRole.GrabStance, true, [\n\t\t\t\tTransition( [BoolCondition( \"b_grab\", false )], \"Idle\", 0.08f, false )\n\t\t\t] ),\n\t\t\tnew( \"GrabGesture1\", WeaponClipRole.GrabGestureOne, false, finishedToIdle ),\n\t\t\tnew( \"GrabGesture2\", WeaponClipRole.GrabGestureTwo, false, finishedToIdle ),\n\t\t\tnew( \"GrabGesture3\", WeaponClipRole.GrabGestureThree, false, finishedToIdle ),\n\t\t\tnew( \"GrabGesture4\", WeaponClipRole.GrabGestureFour, false, finishedToIdle )\n\t\t};\n\n\t\tif ( document.Graph.ReloadProfile == ReloadProfile.Incremental )\n\t\t{\n\t\t\tstates.AddRange(\n\t\t\t[\n\t\t\t\tnew( \"ReloadEnter\", WeaponClipRole.ReloadEnter, false, [\n\t\t\t\t\tTransition( [FinishedCondition()], \"FirstShell\", 0.04f )\n\t\t\t\t] ),\n\t\t\t\tnew( \"FirstShell\", WeaponClipRole.FirstShell, false, [\n\t\t\t\t\tTransition( [BoolCondition( \"b_reloading\", false )], \"ReloadExit\", 0.04f ),\n\t\t\t\t\tTransition( [FinishedCondition()], \"InsertShell\", 0.04f )\n\t\t\t\t] ),\n\t\t\t\tnew( \"InsertShell\", WeaponClipRole.InsertShell, false, [\n\t\t\t\t\tTransition( [BoolCondition( \"b_reloading\", false )], \"ReloadExit\", 0.04f ),\n\t\t\t\t\tTransition( [FinishedCondition()], \"InsertShell\", 0.02f )\n\t\t\t\t] ),\n\t\t\t\tnew( \"ReloadExit\", WeaponClipRole.ReloadExit, false, finishedToIdle )\n\t\t\t] );\n\t\t}\n\n\t\treturn states;\n\t}\n\n\tprivate static string SequenceNode(\n\t\tstring name,\n\t\tstring sequence,\n\t\tWeaponAnimationClip clip,\n\t\tIReadOnlyList<WeaponVisibilityPart> visibilityParts,\n\t\tbool loop,\n\t\tfloat x,\n\t\tfloat y )\n\t{\n\t\tvar id = Id( $\"node:{name}\" );\n\t\tvar visibilityTags = visibilityParts.SelectMany( part =>\n\t\t\tWeaponVisibilityEvaluator.BuildSpans( part, clip ).Select( span =>\n\t\t\t\tnew AnimationTag\n\t\t\t\t{\n\t\t\t\t\tName = span.Name,\n\t\t\t\t\tKind = AnimationTagKind.Range,\n\t\t\t\t\tStartTime = span.StartTime,\n\t\t\t\t\tEndTime = span.EndTime\n\t\t\t\t} ) );\n\t\tvar tagSpans = string.Join( \"\\n\", clip.Tags\n\t\t\t.Concat( visibilityTags )\n\t\t\t.Where( x => !string.IsNullOrWhiteSpace( x.Name ) )\n\t\t\t.OrderBy( x => x.StartTime )\n\t\t\t.ThenBy( x => x.Name )\n\t\t\t.Select( x => TagSpan( x, clip ) ) );\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey = { m_id = {{id}} }\n\t\t\t\t\t\t\tvalue =\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \"CSequenceAnimNode\"\n\t\t\t\t\t\t\t\tm_sName = \"{{name}}\"\n\t\t\t\t\t\t\t\tm_vecPosition = [ {{F( x )}}, {{F( y )}} ]\n\t\t\t\t\t\t\t\tm_nNodeID = { m_id = {{id}} }\n\t\t\t\t\t\t\t\tm_sNote = \"\"\n\t\t\t\t\t\t\t\tm_tagSpans =\n\t\t\t\t\t\t\t\t[\n\t\t\t{{tagSpans}}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\tm_sequenceName = \"{{sequence}}\"\n\t\t\t\t\t\t\t\tm_playbackSpeed = 1.0\n\t\t\t\t\t\t\t\tm_bLoop = {{loop.ToString().ToLowerInvariant()}}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string TagSpan( AnimationTag tag, WeaponAnimationClip clip )\n\t{\n\t\tvar duration = MathF.Max( clip.Duration, 0.0001f );\n\t\tvar start = Math.Clamp( tag.StartTime / duration, 0, 1 );\n\t\tvar tagDuration = tag.Kind == AnimationTagKind.Point\n\t\t\t? MathF.Min( 1.0f / MathF.Max( clip.SampleRate, 1 ) / duration, 1.0f - start )\n\t\t\t: Math.Clamp( (tag.EndTime - tag.StartTime) / duration, 0, 1.0f - start );\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t_class = \"CAnimTagSpan\"\n\t\t\t\t\t\t\t\t\t\tm_id = { m_id = {{Id( $\"tag:{tag.Name}\" )}} }\n\t\t\t\t\t\t\t\t\t\tm_fStartCycle = {{F( start )}}\n\t\t\t\t\t\t\t\t\t\tm_fDuration = {{F( tagDuration )}}\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string StateMachineNode( IEnumerable<StateSpec> states )\n\t{\n\t\tvar stateText = string.Join( \"\\n\", states.Select( StateNode ) );\n\t\tvar id = Id( \"node:StateMachine\" );\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey = { m_id = {{id}} }\n\t\t\t\t\t\t\tvalue =\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \"CStateMachineAnimNode\"\n\t\t\t\t\t\t\t\tm_sName = \"Weapon States\"\n\t\t\t\t\t\t\t\tm_vecPosition = [ -224.0, 304.0 ]\n\t\t\t\t\t\t\t\tm_nNodeID = { m_id = {{id}} }\n\t\t\t\t\t\t\t\tm_sNote = \"\"\n\t\t\t\t\t\t\t\tm_states =\n\t\t\t\t\t\t\t\t[\n\t\t\t{{stateText}}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string StateNode( StateSpec state )\n\t{\n\t\tvar transitions = string.Join( \"\\n\", state.Transitions );\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t_class = \"CAnimState\"\n\t\t\t\t\t\t\t\t\t\tm_transitions =\n\t\t\t\t\t\t\t\t\t\t[\n\t\t\t{{transitions}}\n\t\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t\t\tm_tags = [ ]\n\t\t\t\t\t\t\t\t\t\tm_tagBehaviors = [ ]\n\t\t\t\t\t\t\t\t\t\tm_name = \"{{state.Name}}\"\n\t\t\t\t\t\t\t\t\t\tm_inputConnection =\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tm_nodeID = { m_id = {{Id( $\"node:seq_{state.Name}\" )}} }\n\t\t\t\t\t\t\t\t\t\t\tm_outputID = { m_id = 4294967295 }\n\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\tm_stateID = { m_id = {{Id( $\"state:{state.Name}\" )}} }\n\t\t\t\t\t\t\t\t\t\tm_position = [ 0.0, 0.0 ]\n\t\t\t\t\t\t\t\t\t\tm_bIsStartState = {{state.Start.ToString().ToLowerInvariant()}}\n\t\t\t\t\t\t\t\t\t\tm_bIsEndtState = false\n\t\t\t\t\t\t\t\t\t\tm_bIsPassthrough = false\n\t\t\t\t\t\t\t\t\t\tm_bIsRootMotionExclusive = false\n\t\t\t\t\t\t\t\t\t\tm_bAlwaysEvaluate = false\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string RootNode()\n\t{\n\t\tvar id = Id( \"node:Root\" );\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkey = { m_id = {{id}} }\n\t\t\t\t\t\t\tvalue =\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \"CRootAnimNode\"\n\t\t\t\t\t\t\t\tm_sName = \"Output\"\n\t\t\t\t\t\t\t\tm_vecPosition = [ 48.0, 256.0 ]\n\t\t\t\t\t\t\t\tm_nNodeID = { m_id = {{id}} }\n\t\t\t\t\t\t\t\tm_sNote = \"\"\n\t\t\t\t\t\t\t\tm_inputConnection =\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tm_nodeID = { m_id = {{Id( \"node:StateMachine\" )}} }\n\t\t\t\t\t\t\t\t\tm_outputID = { m_id = 4294967295 }\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string Transition(\n\t\tIEnumerable<string> conditions,\n\t\tstring destination,\n\t\tfloat blend,\n\t\tbool reset = true )\n\t{\n\t\tvar conditionText = string.Join( \"\\n\", conditions );\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t_class = \"CAnimStateTransition\"\n\t\t\t\t\t\t\t\t\t\t\t\tm_conditions =\n\t\t\t\t\t\t\t\t\t\t\t\t[\n\t\t\t{{conditionText}}\n\t\t\t\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t\t\t\t\tm_blendDuration = {{F( blend )}}\n\t\t\t\t\t\t\t\t\t\t\t\tm_destState = { m_id = {{Id( $\"state:{destination}\" )}} }\n\t\t\t\t\t\t\t\t\t\t\t\tm_bReset = {{reset.ToString().ToLowerInvariant()}}\n\t\t\t\t\t\t\t\t\t\t\t\tm_resetCycleOption = \"Beginning\"\n\t\t\t\t\t\t\t\t\t\t\t\tm_flFixedCycleValue = 0.0\n\t\t\t\t\t\t\t\t\t\t\t\tm_blendCurve =\n\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\tm_vControlPoint1 = [ 0.5, 0.0 ]\n\t\t\t\t\t\t\t\t\t\t\t\t\tm_vControlPoint2 = [ 0.5, 1.0 ]\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t\tm_bForceFootPlant = false\n\t\t\t\t\t\t\t\t\t\t\t\tm_bDisabled = false\n\t\t\t\t\t\t\t\t\t\t\t\tm_bRandomTimeBetween = false\n\t\t\t\t\t\t\t\t\t\t\t\tm_flRandomTimeStart = 0.0\n\t\t\t\t\t\t\t\t\t\t\t\tm_flRandomTimeEnd = 0.0\n\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string BoolCondition( string name, bool value ) => $$\"\"\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t_class = \"CParameterAnimCondition\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_comparisonOp = 0\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_paramID = { m_id = {{Id( $\"param:{name}\" )}} }\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_comparisonValue = { m_nType = 1 m_data = {{value.ToString().ToLowerInvariant()}} }\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\n\tprivate static string IntCondition( string name, int value ) => $$\"\"\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t_class = \"CParameterAnimCondition\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_comparisonOp = 0\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_paramID = { m_id = {{Id( $\"param:{name}\" )}} }\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_comparisonValue = { m_nType = 3 m_data = {{value}} }\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\n\tprivate static string FinishedCondition() => \"\"\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t_class = \"CFinishedCondition\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_comparisonOp = 0\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_option = \"FinishedConditionOption_OnAlmostFinished\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tm_bIsFinished = true\n\t\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\n\tprivate static IEnumerable<string> ParameterDefinitions()\n\t{\n\t\tvar pulseBools = new HashSet<string>( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\t\"b_attack\", \"b_attack_dry\", \"b_jump\", \"b_reload\", \"b_deploy\", \"b_inspect\",\n\t\t\t\"b_reloading_shell\", \"b_reloading_first_shell\"\n\t\t};\n\t\tvar bools = new[]\n\t\t{\n\t\t\t\"b_grounded\", \"b_jump\", \"b_sprint\", \"b_attack\", \"b_attack_dry\", \"b_attack_has_hit\",\n\t\t\t\"b_reload\", \"b_empty\", \"b_deploy\", \"b_deploy_skip\", \"b_deploy_first\",\n\t\t\t\"b_twohanded\", \"b_lower_weapon\", \"b_holster\", \"b_grab\", \"b_inspect\",\n\t\t\t\"b_reloading\", \"b_reloading_shell\", \"b_reloading_first_shell\"\n\t\t};\n\t\tforeach ( var name in bools )\n\t\t\tyield return BoolParameter( name, pulseBools.Contains( name ) );\n\n\t\tvar floats = new (string Name, float Default, float Minimum, float Maximum)[]\n\t\t{\n\t\t\t(\"move_bob\", 0, 0, 1),\n\t\t\t(\"move_bob_cycle_control\", 0, 0, 1),\n\t\t\t(\"move_x\", 0, -1, 1),\n\t\t\t(\"move_y\", 0, -1, 1),\n\t\t\t(\"move_z\", 0, -1, 1),\n\t\t\t(\"attack_hold\", 0, 0, 1),\n\t\t\t(\"ironsights_fire_scale\", 0, 0, 1),\n\t\t\t(\"camera_position_scale\", 1, 0, 2),\n\t\t\t(\"camera_rotation_scale\", 1, 0, 2),\n\t\t\t(\"speed_reload\", 1, 0.05f, 5),\n\t\t\t(\"speed_deploy\", 1, 0.05f, 5),\n\t\t\t(\"speed_ironsights\", 1, 0.05f, 5),\n\t\t\t(\"speed_grab\", 1, 0.05f, 5),\n\t\t\t(\"aim_pitch_inertia\", 0, -45, 45),\n\t\t\t(\"aim_yaw_inertia\", 0, -45, 45)\n\t\t};\n\t\tforeach ( var item in floats )\n\t\t\tyield return FloatParameter( item.Name, item.Default, item.Minimum, item.Maximum );\n\n\t\tyield return EnumParameter( \"ironsights\", [\"Hip\", \"ADS\"] );\n\t\tyield return EnumParameter( \"firing_mode\", [\"Safe\", \"Single\", \"Burst\", \"Automatic\"] );\n\t\tyield return EnumParameter( \"weapon_pose\", [\"Default\", \"Alternate\"] );\n\t\tyield return EnumParameter( \"grab_action\", [\"None\", \"Sweep Down\", \"Sweep Right\", \"Sweep Left\", \"Push\"] );\n\t\tyield return EnumParameter( \"deploy_type\", [\"Default\", \"Alternate\"] );\n\t\tyield return EnumParameter( \"reload_type\", [\"Default\", \"Alternate\"] );\n\t\tyield return EnumParameter( \"skeleton\", [\"Human\", \"Citizen\"] );\n\t}\n\n\tprivate static string BoolParameter( string name, bool autoReset ) => $$\"\"\"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"CBoolAnimParameter\"\n\t\t\t\t\t\t\tm_name = \"{{name}}\"\n\t\t\t\t\t\t\tm_id = { m_id = {{Id( $\"param:{name}\" )}} }\n\t\t\t\t\t\t\tm_previewButton = \"ANIMPARAM_BUTTON_NONE\"\n\t\t\t\t\t\t\tm_bUseMostRecentValue = false\n\t\t\t\t\t\t\tm_bAutoReset = {{autoReset.ToString().ToLowerInvariant()}}\n\t\t\t\t\t\t\tm_bDefaultValue = false\n\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\n\tprivate static string FloatParameter( string name, float value, float min, float max ) => $$\"\"\"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"CFloatAnimParameter\"\n\t\t\t\t\t\t\tm_name = \"{{name}}\"\n\t\t\t\t\t\t\tm_id = { m_id = {{Id( $\"param:{name}\" )}} }\n\t\t\t\t\t\t\tm_previewButton = \"ANIMPARAM_BUTTON_NONE\"\n\t\t\t\t\t\t\tm_bUseMostRecentValue = false\n\t\t\t\t\t\t\tm_bAutoReset = false\n\t\t\t\t\t\t\tm_fDefaultValue = {{F( value )}}\n\t\t\t\t\t\t\tm_fMinValue = {{F( min )}}\n\t\t\t\t\t\t\tm_fMaxValue = {{F( max )}}\n\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\n\tprivate static string EnumParameter( string name, IEnumerable<string> choices )\n\t{\n\t\tvar options = string.Join( \"\\n\", choices.Select( x => $\"\\t\\t\\t\\t\\t\\t\\\"{x}\\\",\" ) );\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"CEnumAnimParameter\"\n\t\t\t\t\t\t\tm_name = \"{{name}}\"\n\t\t\t\t\t\t\tm_id = { m_id = {{Id( $\"param:{name}\" )}} }\n\t\t\t\t\t\t\tm_previewButton = \"ANIMPARAM_BUTTON_NONE\"\n\t\t\t\t\t\t\tm_bUseMostRecentValue = false\n\t\t\t\t\t\t\tm_bAutoReset = {{(name == \"grab_action\").ToString().ToLowerInvariant()}}\n\t\t\t\t\t\t\tm_defaultValue = 0\n\t\t\t\t\t\t\tm_enumOptions =\n\t\t\t\t\t\t\t[\n\t\t\t{{options}}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tprivate static IEnumerable<string> StandardTags( WeaponAnimationDocument document )\n\t{\n\t\tvar tags = new HashSet<string>( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\t\"attack_discouraged\",\n\t\t\t\"holster_finished\",\n\t\t\t\"reload_bodygroup\",\n\t\t\t\"reload_increment\"\n\t\t};\n\n\t\tforeach ( var tag in document.Clips.SelectMany( x => x.Tags ) )\n\t\t\ttags.Add( tag.Name );\n\t\tforeach ( var part in document.Rig.VisibilityParts )\n\t\t{\n\t\t\ttags.Add( WeaponVisibilityEvaluator.VisibleTag( part.Id ) );\n\t\t\ttags.Add( WeaponVisibilityEvaluator.HiddenTag( part.Id ) );\n\t\t}\n\n\t\tforeach ( var name in tags.OrderBy( x => x ) )\n\t\t{\n\t\t\tyield return $$\"\"\"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"CStringAnimTag\"\n\t\t\t\t\t\t\tm_name = \"{{name}}\"\n\t\t\t\t\t\t\tm_tagID = { m_id = {{Id( $\"tag:{name}\" )}} }\n\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t\t}\n\t}\n\n\tpublic static uint Id( string value )\n\t{\n\t\tuint crc = 0xFFFFFFFF;\n\t\tforeach ( var data in Encoding.UTF8.GetBytes( value ) )\n\t\t{\n\t\t\tcrc ^= data;\n\t\t\tfor ( var bit = 0; bit < 8; bit++ )\n\t\t\t\tcrc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320 : crc >> 1;\n\t\t}\n\n\t\tvar result = (crc ^ 0xFFFFFFFF) & 0x7FFFFFFF;\n\t\treturn result == 0 ? 1u : result;\n\t}\n\n\tprivate static string F( float value ) =>\n\t\tvalue.ToString( \"0.######\", CultureInfo.InvariantCulture );\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Services/ModelDocWriter.cs",
            "FileName": "ModelDocWriter.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing System.Text;\nusing System.Text.RegularExpressions;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic sealed record HostWeaponMesh(\n\tstring SourcePath,\n\tstring SourceRootBoneName,\n\tTransform ImportTransform,\n\tIReadOnlyList<string> ExcludedBranchRoots,\n\tIReadOnlyList<HostMaterialRemap>? MaterialRemaps = null );\n\npublic sealed record HostMaterialRemap(\n\tstring SourceMaterial,\n\tstring TargetMaterial );\n\npublic sealed record HostAttachment(\n\tstring Name,\n\tstring ParentBone,\n\tVector3 LocalPosition,\n\tRotation LocalRotation );\n\npublic static class ModelDocWriter\n{\n\tpublic const string Header =\n\t\t\"<!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} \" +\n\t\t\"format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} -->\";\n\n\tpublic static string WriteHost(\n\t\tstring referenceMesh,\n\t\tIEnumerable<(WeaponAnimationClip Clip, string Source)> clips,\n\t\tstring animGraphPath,\n\t\tIEnumerable<string> preservedBones,\n\t\tHostWeaponMesh? weaponMesh = null,\n\t\tIEnumerable<HostAttachment>? attachments = null,\n\t\tstring baseModelPath = \"\",\n\t\tIEnumerable<HostMaterialRemap>? baseMaterialRemaps = null )\n\t{\n\t\tvar animationNodes = new StringBuilder();\n\t\tforeach ( var item in clips.OrderBy( x => x.Clip.Name ) )\n\t\t{\n\t\t\tanimationNodes.AppendLine( $$\"\"\"\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \"AnimFile\"\n\t\t\t\t\t\t\t\t\tname = \"{{WeaponAnimationNames.SequenceName( item.Clip )}}\"\n\t\t\t\t\t\t\t\t\tactivity_name = \"\"\n\t\t\t\t\t\t\t\t\tactivity_weight = 1\n\t\t\t\t\t\t\t\t\tweight_list_name = \"\"\n\t\t\t\t\t\t\t\t\tfade_in_time = 0.1\n\t\t\t\t\t\t\t\t\tfade_out_time = 0.1\n\t\t\t\t\t\t\t\t\tlooping = {{item.Clip.Loop.ToString().ToLowerInvariant()}}\n\t\t\t\t\t\t\t\t\tdelta = false\n\t\t\t\t\t\t\t\t\tworldSpace = false\n\t\t\t\t\t\t\t\t\thidden = false\n\t\t\t\t\t\t\t\t\tanim_markup_ordered = false\n\t\t\t\t\t\t\t\t\tdisable_compression = false\n\t\t\t\t\t\t\t\t\tdisable_interpolation = false\n\t\t\t\t\t\t\t\t\tenable_scale = true\n\t\t\t\t\t\t\t\t\tsource_filename = \"{{item.Source}}\"\n\t\t\t\t\t\t\t\t\tstart_frame = -1\n\t\t\t\t\t\t\t\t\tend_frame = -1\n\t\t\t\t\t\t\t\t\tframerate = {{F( item.Clip.SampleRate )}}\n\t\t\t\t\t\t\t\t\ttake = 0\n\t\t\t\t\t\t\t\t\treverse = false\n\t\t\t\t\t\t\t\t},\n\t\t\t\"\"\" );\n\t\t}\n\n\t\tvar weaponMeshNode = BuildWeaponMeshNode( weaponMesh );\n\t\tvar materialGroup = BuildMaterialGroup(\n\t\t\tweaponMesh is not null || !string.IsNullOrWhiteSpace( baseModelPath ),\n\t\t\tweaponMesh?.MaterialRemaps ?? baseMaterialRemaps );\n\t\tvar attachmentList = BuildAttachmentList( attachments );\n\t\tvar boneMarkupNodes = new StringBuilder();\n\t\tforeach ( var boneName in preservedBones\n\t\t\t.Where( name => !string.IsNullOrWhiteSpace( name ) )\n\t\t\t.Distinct( System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( name => name, System.StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tboneMarkupNodes.AppendLine( $$\"\"\"\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t_class = \"BoneMarkup\"\n\t\t\t\t\t\t\t\t\t\t\ttarget_bone = \"{{Escape( boneName )}}\"\n\t\t\t\t\t\t\t\t\t\t\tignore_Translation = false\n\t\t\t\t\t\t\t\t\t\t\tignore_rotation = false\n\t\t\t\t\t\t\t\t\t\t\tdo_not_discard = true\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\"\"\" );\n\t\t}\n\n\t\treturn $$\"\"\"\n\t\t\t{{Header}}\n\t\t\t// SboxWeaponAnimator generated file. Ownership is recorded in weaponanim.manifest.json.\n\t\t\t{\n\t\t\t\trootNode =\n\t\t\t\t{\n\t\t\t\t\t_class = \"RootNode\"\n\t\t\t\t\tchildren =\n\t\t\t\t\t[\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"MaterialGroupList\"\n\t\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t\t[\n\t\t\t{{materialGroup}}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\t\t\tname = \"animation_host\"\n\t\t\t\t\t\t\t\t\tfilename = \"{{referenceMesh}}\"\n\t\t\t\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n\t\t\t\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]\n\t\t\t\t\t\t\t\t\timport_scale = 1.0\n\t\t\t\t\t\t\t\t\talign_origin_x_type = \"None\"\n\t\t\t\t\t\t\t\t\talign_origin_y_type = \"None\"\n\t\t\t\t\t\t\t\t\talign_origin_z_type = \"None\"\n\t\t\t\t\t\t\t\t\tparent_bone = \"\"\n\t\t\t\t\t\t\t\t\timport_filter = { exclude_by_default = false exception_list = [ ] }\n\t\t\t\t\t\t\t\t},\n\t\t\t{{weaponMeshNode}}\n\t\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"AnimationList\"\n\t\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t\t[\n\t\t\t{{animationNodes}}\t\t\t\t]\n\t\t\t\t\t\t\tdefault_root_bone_name = \"\"\n\t\t\t\t\t\t},\n\t\t\t{{attachmentList}}\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"BoneMarkupList\"\n\t\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t\t[\n\t\t\t{{boneMarkupNodes}}\t\t\t\t]\n\t\t\t\t\t\t\tbone_cull_type = \"None\"\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t\tmodel_archetype = \"\"\n\t\t\t\t\tprimary_associated_entity = \"\"\n\t\t\t\t\tanim_graph_name = \"{{animGraphPath}}\"\n\t\t\t\t\tbase_model_name = \"{{Escape( baseModelPath )}}\"\n\t\t\t\t}\n\t\t\t}\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string BuildMaterialGroup(\n\t\tbool includesImportedWeapon,\n\t\tIEnumerable<HostMaterialRemap>? materialRemaps )\n\t{\n\t\tif ( !includesImportedWeapon )\n\t\t{\n\t\t\treturn \"\"\"\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"\n\t\t\t\t\t\t\t\t\tremaps = [ ]\n\t\t\t\t\t\t\t\t\tuse_global_default = false\n\t\t\t\t\t\t\t\t\tglobal_default_material = \"materials/default.vmat\"\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\"\"\";\n\t\t}\n\n\t\tvar remaps = new List<HostMaterialRemap>\n\t\t{\n\t\t\tnew(\n\t\t\t\t\"materials/tools/toolsinvisible.vmat\",\n\t\t\t\t\"materials/tools/toolsinvisible.vmat\" )\n\t\t};\n\t\tremaps.AddRange( materialRemaps?\n\t\t\t.Where( remap => !string.IsNullOrWhiteSpace( remap.SourceMaterial )\n\t\t\t\t&& !string.IsNullOrWhiteSpace( remap.TargetMaterial ) )\n\t\t\t?? [] );\n\n\t\tvar remapText = new StringBuilder();\n\t\tforeach ( var remap in remaps\n\t\t\t.DistinctBy(\n\t\t\t\tremap => remap.SourceMaterial,\n\t\t\t\tSystem.StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy(\n\t\t\t\tremap => remap.SourceMaterial,\n\t\t\t\tSystem.StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tremapText.AppendLine( $$\"\"\"\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tfrom = \"{{Escape( remap.SourceMaterial )}}\"\n\t\t\t\t\t\t\t\t\t\t\tto = \"{{Escape( remap.TargetMaterial )}}\"\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\"\"\" );\n\t\t}\n\n\t\t// Every imported slot is mapped independently. Global substitution would collapse\n\t\t// multi-material weapons to a single texture.\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \"DefaultMaterialGroup\"\n\t\t\t\t\t\t\t\tremaps =\n\t\t\t\t\t\t\t\t[\n\t\t\t{{remapText}}\t\t\t\t\t]\n\t\t\t\t\t\t\t\tuse_global_default = false\n\t\t\t\t\t\t\t\tglobal_default_material = \"materials/default.vmat\"\n\t\t\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string BuildAttachmentList( IEnumerable<HostAttachment>? attachments )\n\t{\n\t\tvar items = attachments?\n\t\t\t.Where( x => !string.IsNullOrWhiteSpace( x.Name )\n\t\t\t\t&& !string.IsNullOrWhiteSpace( x.ParentBone ) )\n\t\t\t.OrderBy( x => x.Name, System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray() ?? [];\n\t\tif ( items.Length == 0 )\n\t\t\treturn \"\";\n\n\t\tvar nodes = new StringBuilder();\n\t\tforeach ( var attachment in items )\n\t\t{\n\t\t\tvar angles = attachment.LocalRotation.Angles();\n\t\t\tnodes.AppendLine( $$\"\"\"\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \"Attachment\"\n\t\t\t\t\t\t\t\t\tname = \"{{Escape( attachment.Name )}}\"\n\t\t\t\t\t\t\t\t\tparent_bone = \"{{Escape( attachment.ParentBone )}}\"\n\t\t\t\t\t\t\t\t\trelative_origin = [ {{F( attachment.LocalPosition.x )}}, {{F( attachment.LocalPosition.y )}}, {{F( attachment.LocalPosition.z )}} ]\n\t\t\t\t\t\t\t\t\trelative_angles = [ {{F( angles.pitch )}}, {{F( angles.yaw )}}, {{F( angles.roll )}} ]\n\t\t\t\t\t\t\t\t\tweight = 1.0\n\t\t\t\t\t\t\t\t\tignore_rotation = false\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\"\"\" );\n\t\t}\n\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \"AttachmentList\"\n\t\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t\t[\n\t\t\t{{nodes}}\t\t\t\t]\n\t\t\t\t\t\t},\n\n\t\t\t\"\"\";\n\t}\n\n\tprivate static string BuildWeaponMeshNode( HostWeaponMesh? source )\n\t{\n\t\tif ( source is null || string.IsNullOrWhiteSpace( source.SourcePath ) )\n\t\t\treturn \"\";\n\n\t\tvar modifiers = new StringBuilder();\n\t\tif ( !string.IsNullOrWhiteSpace( source.SourceRootBoneName )\n\t\t\t&& !source.SourceRootBoneName.Equals(\n\t\t\t\t\"weapon_root\",\n\t\t\t\tSystem.StringComparison.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tmodifiers.AppendLine( $$\"\"\"\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t_class = \"RenameBonePrefix\"\n\t\t\t\t\t\t\t\t\t\t\t\tprefix_to_match = \"{{Escape( source.SourceRootBoneName )}}\"\n\t\t\t\t\t\t\t\t\t\t\t\treplacement = \"weapon_root\"\n\t\t\t\t\t\t\t\t\t\t\t\tallow_nonmatching_bones = true\n\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\"\"\" );\n\t\t}\n\n\t\tvar excluded = source.ExcludedBranchRoots\n\t\t\t.Where( x => !string.IsNullOrWhiteSpace( x ) )\n\t\t\t.Distinct( System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( x => x, System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\t\tif ( excluded.Length > 0 )\n\t\t{\n\t\t\tvar names = string.Join(\n\t\t\t\t\"\\n\",\n\t\t\t\texcluded.Select( x => $\"\\t\\t\\t\\t\\t\\t\\t\\t\\t\\t\\t\\\"{Escape( x )}\\\",\" ) );\n\t\t\tmodifiers.AppendLine( $$\"\"\"\n\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t_class = \"RemoveBoneAndChildren\"\n\t\t\t\t\t\t\t\t\t\t\t\tbone_names =\n\t\t\t\t\t\t\t\t\t\t\t\t[\n\t\t\t\t{{names}}\n\t\t\t\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\"\"\" );\n\t\t}\n\n\t\tvar children = modifiers.Length == 0\n\t\t\t? \"\"\n\t\t\t: $$\"\"\"\n\t\t\t\t\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t\t\t\t\t[\n\t\t\t\t{{modifiers}}\t\t\t\t\t\t\t]\n\n\t\t\t\t\"\"\";\n\t\tvar angles = source.ImportTransform.Rotation.Angles();\n\t\treturn $$\"\"\"\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\t\t\tname = \"weapon\"\n\t\t\t\t\t\t\t\t\tfilename = \"{{Escape( source.SourcePath )}}\"\n\t\t\t\t\t\t\t\t\timport_translation = [ {{F( source.ImportTransform.Position.x )}}, {{F( source.ImportTransform.Position.y )}}, {{F( source.ImportTransform.Position.z )}} ]\n\t\t\t\t\t\t\t\t\timport_rotation = [ {{F( angles.pitch )}}, {{F( angles.yaw )}}, {{F( angles.roll )}} ]\n\t\t\t\t\t\t\t\t\timport_scale = {{F( source.ImportTransform.Scale.x )}}\n\t\t\t\t\t\t\t\t\talign_origin_x_type = \"None\"\n\t\t\t\t\t\t\t\t\talign_origin_y_type = \"None\"\n\t\t\t\t\t\t\t\t\talign_origin_z_type = \"None\"\n\t\t\t\t\t\t\t\t\tparent_bone = \"\"\n\t\t\t\t\t\t\t\t\timport_filter = { exclude_by_default = false exception_list = [ ] }\n\t\t\t{{children}}\t\t\t\t\t},\n\t\t\t\"\"\";\n\t}\n\n\tpublic static string WriteSourceWrapper(\n\t\tstring sourcePath,\n\t\tstring sourceRootBoneName = \"\",\n\t\tSystem.Collections.Generic.IEnumerable<string>? excludedBranchRoots = null,\n\t\tSystem.Collections.Generic.IEnumerable<HostMaterialRemap>? materialRemaps = null )\n\t{\n\t\tvar modifierList = BuildSourceModifierList(\n\t\t\tsourceRootBoneName,\n\t\t\texcludedBranchRoots );\n\t\tvar materialGroup = BuildMaterialGroup( true, materialRemaps );\n\n\t\treturn $$\"\"\"\n\t\t\t{{Header}}\n\t\t\t// SboxWeaponAnimator generated source wrapper.\n\t\t\t{\n\t\t\trootNode =\n\t\t\t{\n\t\t\t\t_class = \"RootNode\"\n\t\t\t\tchildren =\n\t\t\t\t[\n\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"MaterialGroupList\"\n\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t[\n\t\t\t{{materialGroup}}\n\t\t\t\t\t\t]\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\t_class = \"RenderMeshList\"\n\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t[\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \"RenderMeshFile\"\n\t\t\t\t\t\t\t\tname = \"source_weapon\"\n\t\t\t\t\t\t\t\tfilename = \"{{sourcePath}}\"\n\t\t\t\t\t\t\t\timport_translation = [ 0.0, 0.0, 0.0 ]\n\t\t\t\t\t\t\t\timport_rotation = [ 0.0, 0.0, 0.0 ]\n\t\t\t\t\t\t\t\timport_scale = 1.0\n\t\t\t\t\t\t\t\talign_origin_x_type = \"None\"\n\t\t\t\t\t\t\t\talign_origin_y_type = \"None\"\n\t\t\t\t\t\t\t\talign_origin_z_type = \"None\"\n\t\t\t\t\t\t\t\tparent_bone = \"\"\n\t\t\t\t\t\t\t\timport_filter = { exclude_by_default = false exception_list = [ ] }\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t]\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ _class = \"BoneMarkupList\" bone_cull_type = \"None\" },\n\t\t\t{{modifierList}}\t\t\t\n\t\t\t\t\t]\n\t\t\t\t\tmodel_archetype = \"\"\n\t\t\t\tprimary_associated_entity = \"\"\n\t\t\t\tanim_graph_name = \"\"\n\t\t\t\tbase_model_name = \"\"\n\t\t\t\t}\n\t\t\t}\n\t\t\t\"\"\";\n\t}\n\n\tpublic static string WriteVmdlSourceAdapter(\n\t\tstring sourceModelDoc,\n\t\tstring sourceRootBoneName,\n\t\tSystem.Collections.Generic.IEnumerable<string>? excludedBranchRoots = null,\n\t\tTransform? importTransform = null )\n\t{\n\t\tif ( importTransform is { } placement )\n\t\t\tsourceModelDoc = ApplyRenderMeshImportTransform( sourceModelDoc, placement );\n\n\t\tvar modifierList = BuildSourceModifierList(\n\t\t\tsourceRootBoneName,\n\t\t\texcludedBranchRoots );\n\t\tif ( string.IsNullOrWhiteSpace( modifierList ) )\n\t\t\treturn sourceModelDoc;\n\n\t\tvar rootIndex = sourceModelDoc.IndexOf( \"rootNode\", System.StringComparison.Ordinal );\n\t\tvar childrenIndex = rootIndex < 0\n\t\t\t? -1\n\t\t\t: sourceModelDoc.IndexOf( \"children\", rootIndex, System.StringComparison.Ordinal );\n\t\tvar openingBracket = childrenIndex < 0\n\t\t\t? -1\n\t\t\t: sourceModelDoc.IndexOf( '[', childrenIndex );\n\t\tif ( openingBracket < 0 )\n\t\t\tthrow new System.InvalidOperationException( \"The source VMDL does not expose a writable root child list.\" );\n\n\t\tvar insertion = \"\\n\" + modifierList.Trim() + \"\\n\";\n\t\treturn sourceModelDoc.Insert( openingBracket + 1, insertion );\n\t}\n\n\tinternal static string ApplyRenderMeshImportTransform(\n\t\tstring sourceModelDoc,\n\t\tTransform placement )\n\t{\n\t\tvar blocks = new List<(int Start, int End)>();\n\t\tvar search = 0;\n\t\twhile ( true )\n\t\t{\n\t\t\tvar classIndex = sourceModelDoc.IndexOf(\n\t\t\t\t\"_class = \\\"RenderMeshFile\\\"\",\n\t\t\t\tsearch,\n\t\t\t\tStringComparison.Ordinal );\n\t\t\tif ( classIndex < 0 )\n\t\t\t\tbreak;\n\t\t\tvar opening = sourceModelDoc.LastIndexOf( '{', classIndex );\n\t\t\tvar closing = opening < 0 ? -1 : FindClosingBrace( sourceModelDoc, opening );\n\t\t\tif ( opening < 0 || closing < 0 )\n\t\t\t\tthrow new InvalidOperationException(\n\t\t\t\t\t\"The source VMDL contains a malformed RenderMeshFile node.\" );\n\t\t\tblocks.Add( (opening, closing + 1) );\n\t\t\tsearch = closing + 1;\n\t\t}\n\n\t\tif ( blocks.Count == 0 )\n\t\t\tthrow new InvalidOperationException(\n\t\t\t\t\"The source VMDL does not contain an editable RenderMeshFile node.\" );\n\n\t\tvar result = new StringBuilder( sourceModelDoc );\n\t\tforeach ( var (start, end) in blocks.OrderByDescending( block => block.Start ) )\n\t\t{\n\t\t\tvar block = sourceModelDoc[start..end];\n\t\t\tvar sourceAngles = ReadVector( block, \"import_rotation\", Vector3.Zero );\n\t\t\tvar source = new Transform(\n\t\t\t\tReadVector( block, \"import_translation\", Vector3.Zero ),\n\t\t\t\tRotation.From( sourceAngles.x, sourceAngles.y, sourceAngles.z ),\n\t\t\t\tnew Vector3( ReadScalar( block, \"import_scale\", 1 ) ) );\n\t\t\tvar combined = new Transform(\n\t\t\t\tplacement.PointToWorld( source.Position ),\n\t\t\t\tplacement.Rotation * source.Rotation,\n\t\t\t\tplacement.Scale * source.Scale );\n\t\t\tvar angles = combined.Rotation.Angles();\n\t\t\tblock = ReplaceField(\n\t\t\t\tblock,\n\t\t\t\t\"import_translation\",\n\t\t\t\t$\"[ {F( combined.Position.x )}, {F( combined.Position.y )}, {F( combined.Position.z )} ]\" );\n\t\t\tblock = ReplaceField(\n\t\t\t\tblock,\n\t\t\t\t\"import_rotation\",\n\t\t\t\t$\"[ {F( angles.pitch )}, {F( angles.yaw )}, {F( angles.roll )} ]\" );\n\t\t\tblock = ReplaceField( block, \"import_scale\", F( combined.Scale.x ) );\n\t\t\tresult.Remove( start, end - start );\n\t\t\tresult.Insert( start, block );\n\t\t}\n\t\treturn result.ToString();\n\t}\n\n\tprivate static string ReplaceField( string block, string name, string value )\n\t{\n\t\tvar pattern = $@\"(?m)^(\\s*){Regex.Escape( name )}\\s*=\\s*(\\[[^\\]]*\\]|[^\\r\\n]+)\";\n\t\tif ( Regex.IsMatch( block, pattern ) )\n\t\t\treturn new Regex( pattern ).Replace(\n\t\t\t\tblock,\n\t\t\t\t$\"${{1}}{name} = {value}\",\n\t\t\t\t1 );\n\n\t\tvar classLine = block.IndexOf(\n\t\t\t\"_class = \\\"RenderMeshFile\\\"\",\n\t\t\tStringComparison.Ordinal );\n\t\tvar lineEnd = classLine < 0 ? -1 : block.IndexOf( '\\n', classLine );\n\t\tif ( lineEnd < 0 )\n\t\t\tthrow new InvalidOperationException(\n\t\t\t\t$\"The source RenderMeshFile cannot receive '{name}'.\" );\n\t\tvar indentation = Regex.Match( block[(block.LastIndexOf( '\\n', classLine ) + 1)..], @\"^\\s*\" ).Value;\n\t\treturn block.Insert( lineEnd + 1, $\"{indentation}{name} = {value}\\n\" );\n\t}\n\n\tprivate static Vector3 ReadVector( string block, string name, Vector3 fallback )\n\t{\n\t\tvar match = Regex.Match(\n\t\t\tblock,\n\t\t\t$@\"(?m)^\\s*{Regex.Escape( name )}\\s*=\\s*\\[\\s*({NumberPattern})\\s*,\\s*({NumberPattern})\\s*,\\s*({NumberPattern})\\s*\\]\" );\n\t\treturn match.Success\n\t\t\t? new Vector3(\n\t\t\t\tParseNumber( match.Groups[1].Value ),\n\t\t\t\tParseNumber( match.Groups[2].Value ),\n\t\t\t\tParseNumber( match.Groups[3].Value ) )\n\t\t\t: fallback;\n\t}\n\n\tprivate static float ReadScalar( string block, string name, float fallback )\n\t{\n\t\tvar match = Regex.Match(\n\t\t\tblock,\n\t\t\t$@\"(?m)^\\s*{Regex.Escape( name )}\\s*=\\s*({NumberPattern})\" );\n\t\treturn match.Success ? ParseNumber( match.Groups[1].Value ) : fallback;\n\t}\n\n\tprivate static float ParseNumber( string value ) =>\n\t\tfloat.Parse( value, NumberStyles.Float, CultureInfo.InvariantCulture );\n\n\tprivate static int FindClosingBrace( string text, int opening )\n\t{\n\t\tvar depth = 0;\n\t\tvar quoted = false;\n\t\tvar escaped = false;\n\t\tfor ( var index = opening; index < text.Length; index++ )\n\t\t{\n\t\t\tvar character = text[index];\n\t\t\tif ( quoted )\n\t\t\t{\n\t\t\t\tif ( escaped )\n\t\t\t\t\tescaped = false;\n\t\t\t\telse if ( character == '\\\\' )\n\t\t\t\t\tescaped = true;\n\t\t\t\telse if ( character == '\"' )\n\t\t\t\t\tquoted = false;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( character == '\"' )\n\t\t\t{\n\t\t\t\tquoted = true;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( character == '/' && index + 1 < text.Length )\n\t\t\t{\n\t\t\t\tif ( text[index + 1] == '/' )\n\t\t\t\t{\n\t\t\t\t\tindex = text.IndexOf( '\\n', index + 2 );\n\t\t\t\t\tif ( index < 0 )\n\t\t\t\t\t\treturn -1;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif ( text[index + 1] == '*' )\n\t\t\t\t{\n\t\t\t\t\tindex = text.IndexOf( \"*/\", index + 2, StringComparison.Ordinal );\n\t\t\t\t\tif ( index < 0 )\n\t\t\t\t\t\treturn -1;\n\t\t\t\t\tindex++;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ( character == '{' )\n\t\t\t\tdepth++;\n\t\t\telse if ( character == '}' && --depth == 0 )\n\t\t\t\treturn index;\n\t\t}\n\t\treturn -1;\n\t}\n\n\tprivate const string NumberPattern = @\"[-+]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][-+]?\\d+)?\";\n\n\tprivate static string BuildSourceModifierList(\n\t\tstring sourceRootBoneName,\n\t\tSystem.Collections.Generic.IEnumerable<string>? excludedBranchRoots )\n\t{\n\t\tvar modifiers = new System.Collections.Generic.List<string>();\n\t\tif ( !string.IsNullOrWhiteSpace( sourceRootBoneName )\n\t\t\t&& !sourceRootBoneName.Equals( \"weapon_root\", System.StringComparison.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tmodifiers.Add( $$\"\"\"\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t_class = \"RenameBone\"\n\t\t\t\t\t\t\t\t\t\toriginal_bone_name = \"{{Escape( sourceRootBoneName )}}\"\n\t\t\t\t\t\t\t\t\t\tnew_bone_name = \"weapon_root\"\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\"\"\" );\n\t\t}\n\n\t\tvar excluded = excludedBranchRoots?\n\t\t\t.Where( x => !string.IsNullOrWhiteSpace( x ) )\n\t\t\t.Distinct( System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( x => x, System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray() ?? [];\n\t\tif ( excluded.Length > 0 )\n\t\t{\n\t\t\tvar boneNames = string.Join(\n\t\t\t\t\"\\n\",\n\t\t\t\texcluded.Select( x => $\"\\t\\t\\t\\t\\t\\t\\t\\t\\t\\\"{Escape( x )}\\\",\" ) );\n\t\t\tmodifiers.Add( $$\"\"\"\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t_class = \"RemoveBoneAndChildren\"\n\t\t\t\t\t\t\t\t\t\tbone_names =\n\t\t\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t\t\t{{boneNames}}\n\t\t\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\"\"\" );\n\t\t}\n\n\t\tvar modifierList = modifiers.Count == 0\n\t\t\t\t? \"\"\n\t\t\t\t: $$\"\"\"\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \"ModelModifierList\"\n\t\t\t\t\t\t\t\tchildren =\n\t\t\t\t\t\t\t\t[\n\t\t\t\t\t\t\t\t{{string.Join( \"\\n\", modifiers )}}\n\t\t\t\t\t\t\t\t]\n\t\t\t\t\t\t\t},\n\n\t\t\t\t\t\t\"\"\";\n\t\treturn modifierList;\n\t}\n\n\tprivate static string F( float value ) =>\n\t\tvalue.ToString( \"0.######\", CultureInfo.InvariantCulture );\n\n\tprivate static string Escape( string value ) =>\n\t\tvalue.Replace( \"\\\\\", \"\\\\\\\\\" ).Replace( \"\\\"\", \"\\\\\\\"\" );\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Widgets/WeaponAnimatorTheme.cs",
            "FileName": "WeaponAnimatorTheme.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic static class WeaponAnimatorTheme\n{\n\tpublic static readonly Color Background = new( 0.052f, 0.058f, 0.064f );\n\tpublic static readonly Color Surface = new( 0.082f, 0.091f, 0.101f );\n\tpublic static readonly Color SurfaceRaised = new( 0.105f, 0.115f, 0.126f );\n\tpublic static readonly Color Border = Color.White.WithAlpha( 0.075f );\n\tpublic static readonly Color Text = new( 0.88f, 0.90f, 0.92f );\n\tpublic static readonly Color Muted = new( 0.52f, 0.56f, 0.61f );\n\tpublic static readonly Color Cyan = new( 0.15f, 0.78f, 0.91f );\n\tpublic static readonly Color Amber = new( 0.96f, 0.61f, 0.16f );\n\tpublic static readonly Color Green = new( 0.34f, 0.82f, 0.50f );\n\tpublic static readonly Color Coral = new( 0.98f, 0.38f, 0.34f );\n\tpublic const float ScrollbarGutter = 14;\n\n\t/// <summary>\n\t/// Arm bone depth ramp, root to fingertips. Saturation stays high across the whole arc - an\n\t/// earlier version faded toward white at the fingertips, and desaturated colours collapse\n\t/// together against the dark viewport, which is exactly where the bones are densest. Hue\n\t/// carries the signal instead, sweeping violet through cyan to chartreuse, staying clear of\n\t/// Amber (weapon bones) and Coral (IK bones).\n\t/// </summary>\n\tprivate static readonly Color[] BoneDepthRamp =\n\t[\n\t\tnew( 0.58f, 0.24f, 1.00f ),\n\t\tnew( 0.30f, 0.45f, 1.00f ),\n\t\tnew( 0.08f, 0.68f, 1.00f ),\n\t\tnew( 0.10f, 0.92f, 0.94f ),\n\t\tnew( 0.16f, 1.00f, 0.58f ),\n\t\tnew( 0.52f, 1.00f, 0.30f ),\n\t\tnew( 0.82f, 1.00f, 0.24f )\n\t];\n\n\t/// <summary>\n\t/// Samples the bone depth ramp. <paramref name=\"fraction\"/> is 0 at the skeleton root and 1 at\n\t/// the deepest bone.\n\t/// </summary>\n\tpublic static Color BoneDepthColor( float fraction )\n\t{\n\t\tif ( !float.IsFinite( fraction ) )\n\t\t\treturn BoneDepthRamp[0];\n\n\t\tvar clamped = Math.Clamp( fraction, 0, 1 );\n\t\tvar scaled = clamped * (BoneDepthRamp.Length - 1);\n\t\tvar index = Math.Clamp( (int)scaled, 0, BoneDepthRamp.Length - 2 );\n\t\treturn Color.Lerp(\n\t\t\tBoneDepthRamp[index],\n\t\t\tBoneDepthRamp[index + 1],\n\t\t\tscaled - index );\n\t}\n\n\tpublic const string PanelStyle =\n\t\t\"background-color: rgb(21,23,26);\" +\n\t\t\"border: 1px solid rgba(255,255,255,0.075);\" +\n\t\t\"border-radius: 3px;\";\n\n\tpublic const string InputStyle =\n\t\t\"background-color: rgb(13,15,17);\" +\n\t\t\"border: 1px solid rgba(255,255,255,0.09);\" +\n\t\t\"border-radius: 3px;\" +\n\t\t\"color: rgb(224,229,234);\" +\n\t\t\"selection-background-color: rgb(31,126,151);\" +\n\t\t\"padding: 0 7px;\" +\n\t\t\"font-size: 11px;\";\n\n\tpublic static Sandbox.UI.Margin ScrollCanvasMargin( float padding = 0 ) =>\n\t\tnew( padding, padding, padding + ScrollbarGutter, padding );\n\n\tpublic static Button Button(\n\t\tstring text,\n\t\tstring icon,\n\t\tSystem.Action clicked,\n\t\tWidget? parent = null,\n\t\tbool primary = false )\n\t{\n\t\tvar button = new WeaponAnimatorButton( text, icon, parent )\n\t\t{\n\t\t\tClicked = clicked,\n\t\t\tFixedHeight = 28,\n\t\t\tTint = primary ? Cyan * 0.65f : SurfaceRaised,\n\t\t\tToolTip = text\n\t\t};\n\t\treturn button;\n\t}\n\n\tpublic static Label Label( string text, Widget parent = null, bool muted = false )\n\t{\n\t\tvar label = new Label( text, parent )\n\t\t{\n\t\t\tColor = muted ? Muted : Text\n\t\t};\n\t\tlabel.SetStyles(\n\t\t\t\"background-color: transparent; border: none; padding: 0px;\" +\n\t\t\t$\"font-size: 11px; color: {(muted ? Muted : Text).Hex};\" );\n\t\treturn label;\n\t}\n\n\tpublic static Label SectionLabel(\n\t\tstring text,\n\t\tWidget parent,\n\t\tColor? color = null,\n\t\tbool topMargin = false )\n\t{\n\t\tvar label = new Label( text, parent )\n\t\t{\n\t\t\tColor = color ?? Muted\n\t\t};\n\t\tlabel.SetStyles(\n\t\t\t\"background-color: transparent; border: none; padding: 0px;\" +\n\t\t\t$\"font-size: 9px; font-weight: 600; letter-spacing: 0.65px; color: {(color ?? Muted).Hex};\" +\n\t\t\t(topMargin ? \"margin-top: 7px;\" : \"\") );\n\t\treturn label;\n\t}\n}\n\npublic sealed class WeaponAnimatorButton : Button\n{\n\tpublic bool Flat { get; set; }\n\n\tpublic WeaponAnimatorButton( string text, Widget? parent = null ) : base( text, parent )\n\t{\n\t\tToolTip = text;\n\t}\n\n\tpublic WeaponAnimatorButton( string text, string icon, Widget? parent = null )\n\t\t: base( text, icon, parent )\n\t{\n\t\tToolTip = text;\n\t}\n\n\tprotected override Vector2 SizeHint() => PreferredSize();\n\tprotected override Vector2 MinimumSizeHint() => PreferredSize();\n\tpublic float PreferredWidth => PreferredSize().x;\n\n\tpublic void FitToContent( bool fixedWidth = false )\n\t{\n\t\tvar width = MathF.Ceiling( PreferredWidth );\n\t\tMinimumWidth = width;\n\t\tif ( fixedWidth )\n\t\t\tFixedWidth = width;\n\t\tUpdate();\n\t}\n\n\tprivate Vector2 PreferredSize()\n\t{\n\t\tPaint.SetDefaultFont();\n\t\tvar hasIcon = !string.IsNullOrWhiteSpace( Icon );\n\t\tvar textWidth = string.IsNullOrWhiteSpace( Text ) ? 0 : Paint.MeasureText( Text ).x;\n\t\tvar content = ContentLayout( 0, textWidth, hasIcon );\n\t\treturn new Vector2(\n\t\t\tMathF.Max( 36, 20 + content.IconWidth + content.Gap + textWidth ),\n\t\t\t28 );\n\t}\n\n\tinternal static (float StartX, float IconWidth, float Gap) ContentLayout(\n\t\tfloat centerX,\n\t\tfloat textWidth,\n\t\tbool hasIcon )\n\t{\n\t\tconst float iconSize = 15;\n\t\tconst float spacing = 4;\n\t\tvar hasText = textWidth > 0;\n\t\tvar gap = hasIcon && hasText ? spacing : 0;\n\t\tvar iconWidth = hasIcon ? iconSize : 0;\n\t\tvar contentWidth = textWidth + iconWidth + gap;\n\t\treturn (centerX - contentWidth * 0.5f, iconWidth, gap);\n\t}\n\n\tprotected override void OnPaint()\n\t{\n\t\tvar color = Tint.ToHsv();\n\t\tvar background = color;\n\t\tif ( Flat )\n\t\t{\n\t\t\tbackground = Color.Transparent;\n\t\t\tcolor = Enabled\n\t\t\t\t? color\n\t\t\t\t: Theme.SurfaceLightBackground.WithAlpha( 0.35f );\n\t\t\tif ( Enabled && Paint.HasMouseOver )\n\t\t\t\tcolor = color with { Value = MathF.Min( color.Value + 0.18f, 1.0f ) };\n\t\t}\n\t\telse if ( Enabled )\n\t\t{\n\t\t\tif ( Paint.HasPressed )\n\t\t\t\tbackground = color with { Value = color.Value + 0.1f };\n\t\t\telse if ( Paint.HasMouseOver )\n\t\t\t\tbackground = color with { Value = color.Value + 0.2f };\n\t\t}\n\t\telse\n\t\t{\n\t\t\tbackground = color = Theme.SurfaceLightBackground;\n\t\t}\n\n\t\tif ( !Flat && (!Enabled || ReadOnly) )\n\t\t{\n\t\t\tcolor = color.WithSaturation( 0.1f ).WithAlpha( 0.5f );\n\t\t\tbackground = color.WithAlpha( 0.2f );\n\t\t}\n\n\t\tif ( background.Alpha > 0 )\n\t\t{\n\t\t\tPaint.Antialiasing = true;\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( background with\n\t\t\t{\n\t\t\t\tValue = background.Value + 0.04f,\n\t\t\t\tSaturation = color.Saturation * 0.8f\n\t\t\t} );\n\t\t\tPaint.DrawRect( LocalRect, 3 );\n\t\t\tPaint.SetBrushLinear(\n\t\t\t\tLocalRect.TopLeft,\n\t\t\t\tLocalRect.BottomRight,\n\t\t\t\tbackground,\n\t\t\t\tbackground with { Value = background.Value - 0.03f } );\n\t\t\tPaint.DrawRect( LocalRect.Shrink( 1 ), 3 );\n\t\t}\n\t\telse if ( !Flat )\n\t\t{\n\t\t\tcolor = Color.White.WithAlpha( 0.5f );\n\t\t}\n\n\t\tPaint.SetDefaultFont();\n\t\tPaint.SetPen( color with { Value = 0.99f, Saturation = color.Saturation * 0.20f } );\n\n\t\tconst float iconSize = 15;\n\t\tvar hasIcon = !string.IsNullOrWhiteSpace( Icon );\n\t\tvar displayedText = Text ?? \"\";\n\t\tvar measuredText = string.IsNullOrEmpty( displayedText )\n\t\t\t? Vector2.Zero\n\t\t\t: Paint.MeasureText( displayedText );\n\t\tvar content = ContentLayout(\n\t\t\tLocalRect.Center.x,\n\t\t\tmeasuredText.x,\n\t\t\thasIcon );\n\t\tvar cursorX = content.StartX;\n\n\t\tif ( hasIcon )\n\t\t{\n\t\t\tPaint.DrawIcon(\n\t\t\t\tnew Rect( cursorX, LocalRect.Center.y - iconSize * 0.5f, iconSize, iconSize ),\n\t\t\t\tIcon,\n\t\t\t\ticonSize );\n\t\t\tcursorX += content.IconWidth + content.Gap;\n\t\t}\n\n\t\tif ( measuredText.x > 0 )\n\t\t{\n\t\t\tPaint.DrawText(\n\t\t\t\tnew Rect( cursorX, LocalRect.Top, measuredText.x, LocalRect.Height ),\n\t\t\t\tdisplayedText,\n\t\t\t\tTextFlag.Center );\n\t\t}\n\t}\n}\n\npublic sealed class PanelChrome : Widget\n{\n\tpublic Widget Body { get; }\n\tpublic Label TitleLabel { get; }\n\tpublic Label StatusLabel { get; }\n\n\tpublic PanelChrome( string title, string icon, Widget? content = null, Widget? parent = null )\n\t\t: base( parent )\n\t{\n\t\tSetStyles( WeaponAnimatorTheme.PanelStyle );\n\t\tLayout = Layout.Column();\n\t\tLayout.Margin = 0;\n\t\tLayout.Spacing = 0;\n\n\t\tvar header = new Widget( this )\n\t\t{\n\t\t\tFixedHeight = 34\n\t\t};\n\t\theader.SetStyles(\n\t\t\t\"background-color: rgb(27,30,34); border: none;\" );\n\t\theader.Layout = Layout.Row();\n\t\theader.Layout.Margin = new Sandbox.UI.Margin( 10, 0, 10, 0 );\n\t\theader.Layout.Spacing = 7;\n\n\t\tvar iconLabel = new Label( icon, header );\n\t\ticonLabel.SetStyles(\n\t\t\t\"background-color: transparent; border: none; padding: 0px;\" +\n\t\t\t$\"font-family: Material Icons; font-size: 15px; color: {WeaponAnimatorTheme.Cyan.Hex};\" );\n\t\ticonLabel.FixedWidth = 18;\n\t\theader.Layout.Add( iconLabel );\n\n\t\tTitleLabel = WeaponAnimatorTheme.Label( title, header );\n\t\tTitleLabel.SetStyles(\n\t\t\t\"background-color: transparent; border: none; padding: 0px;\" +\n\t\t\t$\"font-size: 10px; font-weight: 600; letter-spacing: 0.65px; color: {WeaponAnimatorTheme.Text.Hex};\" );\n\t\theader.Layout.Add( TitleLabel );\n\t\theader.Layout.AddStretchCell();\n\n\t\tStatusLabel = WeaponAnimatorTheme.Label( \"\", header, true );\n\t\theader.Layout.Add( StatusLabel );\n\t\tLayout.Add( header );\n\t\tvar separator = new Widget( this ) { FixedHeight = 1 };\n\t\tseparator.SetStyles( \"background-color: rgba(255,255,255,0.07); border: none;\" );\n\t\tLayout.Add( separator );\n\n\t\tBody = content ?? new Widget( this );\n\t\tBody.SetStyles( \"background-color: transparent; border: none;\" );\n\t\tBody.Parent = this;\n\t\tLayout.Add( Body, 1 );\n\t}\n}\n\npublic sealed class WeaponAnimatorToolbar : Widget\n{\n\tprivate readonly Widget _left;\n\tprivate readonly Widget _center;\n\tprivate readonly Widget _right;\n\tprivate readonly System.Collections.Generic.List<ToolbarAction> _leftActions = [];\n\tprivate WeaponAnimatorButton? _overflowButton;\n\n\tpublic WeaponAnimatorToolbar( Widget? parent = null ) : base( parent )\n\t{\n\t\tFixedHeight = 48;\n\t\tSetStyles(\n\t\t\t\"background-color: rgb(18,20,23);\" +\n\t\t\t\"border-bottom: 1px solid rgba(255,255,255,0.08);\" );\n\t\tvar grid = Layout.Grid();\n\t\tgrid.Margin = new Sandbox.UI.Margin( 10, 8, 10, 8 );\n\t\tgrid.HorizontalSpacing = 5;\n\t\tgrid.SetColumnStretch( 1, 1, 1 );\n\t\tLayout = grid;\n\n\t\t_left = Section( this );\n\t\t_center = Section( this );\n\t\t_right = Section( this );\n\t\tgrid.AddCell( 0, 0, _left, alignment: TextFlag.LeftCenter );\n\t\tgrid.AddCell( 1, 0, _center, alignment: TextFlag.Center );\n\t\tgrid.AddCell( 2, 0, _right, alignment: TextFlag.RightCenter );\n\t}\n\n\tpublic Button AddLeft(\n\t\tstring text,\n\t\tstring icon,\n\t\tSystem.Action clicked,\n\t\tbool primary = false,\n\t\tbool overflowAtNarrowWidth = false )\n\t{\n\t\tvar button = AddButton( _left, text, icon, clicked, primary );\n\t\t_leftActions.Add( new ToolbarAction(\n\t\t\t(WeaponAnimatorButton)button,\n\t\t\ttext,\n\t\t\tclicked,\n\t\t\toverflowAtNarrowWidth ) );\n\t\treturn button;\n\t}\n\n\tpublic Button AddCenter( string text, string icon, System.Action clicked, bool primary = false ) =>\n\t\tAddButton( _center, text, icon, clicked, primary );\n\n\tpublic Button AddRight( string text, string icon, System.Action clicked, bool primary = false ) =>\n\t\tAddButton( _right, text, icon, clicked, primary );\n\n\tpublic void BalanceCenter()\n\t{\n\t\tEnsureOverflowButton();\n\t\tApplyAvailableWidth( Width );\n\t}\n\n\tpublic void Clear()\n\t{\n\t\t_left.Layout.Clear( true );\n\t\t_center.Layout.Clear( true );\n\t\t_right.Layout.Clear( true );\n\t\t_left.MinimumWidth = 0;\n\t\t_center.MinimumWidth = 0;\n\t\t_right.MinimumWidth = 0;\n\t\t_leftActions.Clear();\n\t\t_overflowButton = null;\n\t}\n\n\tprotected override void OnResize()\n\t{\n\t\tbase.OnResize();\n\t\tApplyAvailableWidth( Width );\n\t}\n\n\tinternal void ApplyAvailableWidth( float availableWidth )\n\t{\n\t\tUpdateOverflow( availableWidth );\n\t\tvar sideWidth = MathF.Max( ContentWidth( _left ), ContentWidth( _right ) );\n\t\t_left.MinimumWidth = sideWidth;\n\t\t_right.MinimumWidth = sideWidth;\n\t}\n\n\tprivate void EnsureOverflowButton()\n\t{\n\t\tif ( _overflowButton is not null || _leftActions.All( x => !x.OverflowAtNarrowWidth ) )\n\t\t\treturn;\n\n\t\t_overflowButton = (WeaponAnimatorButton)AddButton(\n\t\t\t_left,\n\t\t\t\"More\",\n\t\t\t\"more_horiz\",\n\t\t\tShowOverflowMenu,\n\t\t\tfalse );\n\t\t_overflowButton.Visible = false;\n\t}\n\n\tprivate void UpdateOverflow( float availableWidth )\n\t{\n\t\tif ( _overflowButton is null )\n\t\t\treturn;\n\n\t\tvar narrow = availableWidth > 0 && availableWidth < 1380;\n\t\tforeach ( var action in _leftActions.Where( x => x.OverflowAtNarrowWidth ) )\n\t\t\taction.Button.Visible = !narrow;\n\t\t_overflowButton.Visible = narrow;\n\t}\n\n\tinternal bool UsesOverflow => _overflowButton?.Visible == true;\n\n\tprivate void ShowOverflowMenu()\n\t{\n\t\tif ( _overflowButton is null )\n\t\t\treturn;\n\n\t\tvar menu = new Menu( _overflowButton );\n\t\tforeach ( var action in _leftActions.Where( x => x.OverflowAtNarrowWidth ) )\n\t\t\tmenu.AddOption( action.Text, null, action.Clicked );\n\t\tmenu.OpenAt( _overflowButton.ScreenRect.BottomLeft );\n\t}\n\n\tprivate static float ContentWidth( Widget section ) =>\n\t\tsection.Children\n\t\t\t.OfType<WeaponAnimatorButton>()\n\t\t\t.Where( x => x.Visible )\n\t\t\t.Sum( x => x.PreferredWidth + 5 );\n\n\tprivate static Button AddButton(\n\t\tWidget section,\n\t\tstring text,\n\t\tstring icon,\n\t\tSystem.Action clicked,\n\t\tbool primary )\n\t{\n\t\tvar button = WeaponAnimatorTheme.Button( text, icon, clicked, section, primary );\n\t\tsection.Layout.Add( button );\n\t\tif ( button is WeaponAnimatorButton animatorButton )\n\t\t\tanimatorButton.FitToContent( true );\n\t\treturn button;\n\t}\n\n\tprivate static Widget Section( Widget parent )\n\t{\n\t\tvar section = new Widget( parent );\n\t\tsection.SetStyles( \"background-color: transparent; border: none;\" );\n\t\tsection.Layout = Layout.Row();\n\t\tsection.Layout.Margin = 0;\n\t\tsection.Layout.Spacing = 5;\n\t\treturn section;\n\t}\n\n\tprivate sealed record ToolbarAction(\n\t\tWeaponAnimatorButton Button,\n\t\tstring Text,\n\t\tSystem.Action Clicked,\n\t\tbool OverflowAtNarrowWidth );\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Code/Runtime/WeaponAnimationAsset.cs",
            "FileName": "WeaponAnimationAsset.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing Sandbox;\n\nnamespace SboxWeaponAnimator;\n\n[AssetType(\n\tName = \"Weapon Animation Project\",\n\tExtension = \"wepanim\",\n\tCategory = \"Animation\",\n\tFlags = AssetTypeFlags.NoEmbedding )]\npublic sealed class WeaponAnimationAsset : GameResource\n{\n\t[Property, Hide]\n\tpublic WeaponAnimationDocument Document { get; set; } = WeaponAnimationDocument.CreateDefault();\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Services/AssetGenerationService.cs",
            "FileName": "AssetGenerationService.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Diagnostics;\nusing System.IO;\nusing System.Linq;\nusing System.Security.Cryptography;\nusing System.Text;\nusing System.Threading;\nusing System.Threading.Tasks;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic sealed class GenerationResult\n{\n\tpublic bool Success { get; init; }\n\tpublic bool Cancelled { get; init; }\n\tpublic string OutputFolder { get; init; } = \"\";\n\tpublic ValidationReport Validation { get; init; } = new();\n\tpublic List<GenerationDiagnostic> Diagnostics { get; init; } = [];\n\tpublic List<string> GeneratedFiles { get; init; } = [];\n}\n\npublic sealed record GenerationProgress(\n\tstring Stage,\n\tstring Detail,\n\tint Completed = 0,\n\tint Total = 0 );\n\npublic sealed class AssetGenerationService\n{\n\tpublic const string GeneratorVersion = \"2.1.0\";\n\tprivate const string ManifestFile = \"weaponanim.manifest.json\";\n\n\tpublic async Task<GenerationResult> GenerateAsync(\n\t\tWeaponAnimationDocument document,\n\t\tAction<GenerationProgress>? progress = null,\n\t\tCancellationToken cancellationToken = default )\n\t{\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\tvar totalTimer = Stopwatch.StartNew();\n\t\tvar previousStageMilliseconds = 0L;\n\t\tvoid LogStage( string stage, string execution )\n\t\t{\n\t\t\tvar totalMilliseconds = totalTimer.ElapsedMilliseconds;\n\t\t\tLog.Info(\n\t\t\t\t$\"[Weapon Animator] generation timing: {stage} took \"\n\t\t\t\t+ $\"{totalMilliseconds - previousStageMilliseconds} ms \"\n\t\t\t\t+ $\"({execution}, total {totalMilliseconds} ms).\" );\n\t\t\tpreviousStageMilliseconds = totalMilliseconds;\n\t\t}\n\n\t\tWeaponAnimationDocument generationDocument;\n\t\ttry\n\t\t{\n\t\t\tgenerationDocument = CreateGenerationSnapshot( document );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Error( $\"[Weapon Animator] could not snapshot the project for generation: {ex}\" );\n\t\t\treturn Failed(\n\t\t\t\tnew ValidationReport(),\n\t\t\t\t\"generation.snapshot\",\n\t\t\t\t$\"Could not snapshot the project for generation: {ex.Message}\" );\n\t\t}\n\t\tLogStage( \"snapshot\", \"editor thread\" );\n\n\t\tprogress?.Invoke( new GenerationProgress( \"Validate\", \"Validating the weapon project\" ) );\n\t\tvar validation = WeaponAnimationValidator.ValidateForGeneration( generationDocument );\n\t\tif ( !validation.IsValid )\n\t\t\treturn Failed( validation, \"generation.validation\", \"Generation is blocked by validation errors.\" );\n\t\tLogStage( \"validation\", \"editor thread\" );\n\n\t\tstring outputRoot;\n\t\tstring relativeRoot;\n\t\ttry\n\t\t{\n\t\t\toutputRoot = ResolveOutputRoot( generationDocument );\n\t\t\trelativeRoot = WeaponSourceImporter.RelativeAssetPath( outputRoot );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Error( $\"[Weapon Animator] output path resolution failed: {ex}\" );\n\t\t\treturn Failed(\n\t\t\t\tvalidation,\n\t\t\t\t\"generation.output\",\n\t\t\t\t$\"Could not prepare the generated output folder: {ex.Message}\" );\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tLoadOwnershipManifest( generationDocument, outputRoot );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Error( $\"[Weapon Animator] could not load the ownership manifest: {ex}\" );\n\t\t\treturn Failed(\n\t\t\t\tvalidation,\n\t\t\t\t\"ownership.manifest\",\n\t\t\t\t$\"Could not read the generated ownership manifest: {ex.Message}\" );\n\t\t}\n\t\tLogStage( \"paths and ownership\", \"editor thread\" );\n\n\t\tvar diagnostics = new List<GenerationDiagnostic>();\n\t\tHostSkeleton skeleton;\n\t\tDictionary<string, string> files;\n\t\tIReadOnlyList<WeaponMaterialPipeline.GeneratedTextureCopy> textureCopies;\n\t\ttry\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tprogress?.Invoke( new GenerationProgress( \"Prepare\", \"Building generated source files\" ) );\n\t\t\tskeleton = HostSkeletonBuilder.Build( generationDocument );\n\t\t\tfiles = await BuildFilesResponsiveAsync(\n\t\t\t\tgenerationDocument,\n\t\t\t\tskeleton,\n\t\t\t\trelativeRoot,\n\t\t\t\tprogress,\n\t\t\t\tcancellationToken );\n\t\t\ttextureCopies = WeaponMaterialPipeline.BuildOutputTextureCopies( generationDocument );\n\t\t\tLogStage(\n\t\t\t\t\"source assembly\",\n\t\t\t\t\"sequence sampling on worker; final assembly on editor thread\" );\n\t\t}\n\t\tcatch ( OperationCanceledException )\n\t\t{\n\t\t\treturn CancelledResult( validation, outputRoot );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Error( $\"[Weapon Animator] could not assemble generated sources: {ex}\" );\n\t\t\treturn Failed(\n\t\t\t\tvalidation,\n\t\t\t\t\"generation.sources\",\n\t\t\t\t$\"Could not assemble generated sources: {ex.Message}\" );\n\t\t}\n\t\tvar previousFiles = generationDocument.Manifest.Files\n\t\t\t.Select( x => x.RelativePath )\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\n\t\tvar generatedSourcePaths = files.Keys\n\t\t\t.Concat( textureCopies.Select( copy => copy.RelativePath ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\t\tforeach ( var file in generatedSourcePaths )\n\t\t{\n\t\t\tvar absolute = Path.Combine( outputRoot, file );\n\t\t\tif ( File.Exists( absolute ) && !previousFiles.Contains( file ) )\n\t\t\t{\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\t\"ownership.conflict\",\n\t\t\t\t\t$\"Refusing to replace unowned file '{file}'.\",\n\t\t\t\t\tabsolute ) );\n\t\t\t}\n\t\t}\n\n\t\tif ( diagnostics.Any( x => x.Severity == ValidationSeverity.Error ) )\n\t\t\treturn new GenerationResult\n\t\t\t{\n\t\t\t\tSuccess = false,\n\t\t\t\tOutputFolder = outputRoot,\n\t\t\t\tValidation = validation,\n\t\t\t\tDiagnostics = diagnostics\n\t\t\t};\n\t\tLogStage( \"ownership conflict check\", \"editor thread\" );\n\n\t\t// Generation compiles straight into the output folder. A throwaway staging copy is not\n\t\t// safe here: once ModelDoc compiles a .vmdl the asset database records its .dmx\n\t\t// dependencies, and deleting those sources afterwards leaves the asset permanently\n\t\t// out of date, which the engine then retries every frame for the rest of the session.\n\t\t// The backup and rollback below already restore owned outputs when a compile fails.\n\t\tDiscardAbandonedStage( generationDocument );\n\t\tLogStage( \"abandoned-stage cleanup\", \"editor thread\" );\n\n\t\tvar backups = new Dictionary<string, byte[]>( StringComparer.OrdinalIgnoreCase );\n\t\tvar newFiles = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\ttry\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tprogress?.Invoke( new GenerationProgress( \"Write\", \"Writing persistent generation sources\" ) );\n\t\t\tDirectory.CreateDirectory( outputRoot );\n\t\t\tPrepareCompiledConsumersForRewrite(\n\t\t\t\toutputRoot,\n\t\t\t\tgeneratedSourcePaths,\n\t\t\t\tbackups,\n\t\t\t\tnewFiles );\n\t\t\tLogStage( \"consumer reset\", \"editor thread\" );\n\t\t\tawait RunResponsiveWorkerAsync( \"persistent source writes\", () =>\n\t\t\t{\n\t\t\t\tWriteTextureCopies(\n\t\t\t\t\toutputRoot,\n\t\t\t\t\ttextureCopies,\n\t\t\t\t\tbackups,\n\t\t\t\t\tnewFiles,\n\t\t\t\t\tpreviousFiles,\n\t\t\t\t\tcancellationToken );\n\t\t\t\tWriteFiles(\n\t\t\t\t\toutputRoot,\n\t\t\t\t\tfiles,\n\t\t\t\t\tbackups,\n\t\t\t\t\tnewFiles,\n\t\t\t\t\tpreviousFiles,\n\t\t\t\t\tcancellationToken );\n\t\t\t\tVerifyGeneratedSources( outputRoot, generatedSourcePaths );\n\t\t\t\treturn true;\n\t\t\t}, cancellationToken );\n\t\t\tLogStage( \"persistent source writes\", \"worker\" );\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\"Register\",\n\t\t\t\t\"Registering generated dependencies\",\n\t\t\t\t0,\n\t\t\t\tgeneratedSourcePaths.Length ) );\n\t\t\tawait RegisterGeneratedDependenciesAsync(\n\t\t\t\toutputRoot,\n\t\t\t\tgeneratedSourcePaths,\n\t\t\t\tprogress,\n\t\t\t\tcancellationToken );\n\t\t\tLogStage( \"dependency registration\", \"editor thread with per-file yields\" );\n\n\t\t\tawait CompileAndInspectAsync(\n\t\t\t\tgenerationDocument,\n\t\t\t\toutputRoot,\n\t\t\t\trelativeRoot,\n\t\t\t\tskeleton,\n\t\t\t\tdiagnostics,\n\t\t\t\tprogress,\n\t\t\t\tcancellationToken );\n\t\t\tLogStage( \"compile and inspection\", \"asset system/resource compiler\" );\n\t\t\tif ( diagnostics.Any( x => x.Severity == ValidationSeverity.Error ) )\n\t\t\t\tthrow new InvalidOperationException( \"One or more generated assets failed to compile or reload.\" );\n\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\"Finalize\",\n\t\t\t\t\"Removing obsolete owned files\" ) );\n\t\t\tRemoveObsoleteOwnedFiles(\n\t\t\t\tgenerationDocument,\n\t\t\t\toutputRoot,\n\t\t\t\tgeneratedSourcePaths,\n\t\t\t\tdiagnostics );\n\t\t\tLogStage( \"obsolete output cleanup\", \"editor thread\" );\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\"Finalize\",\n\t\t\t\t\"Hashing generated sources and writing the manifest\" ) );\n\t\t\tvar manifest = await RunResponsiveWorkerAsync( \"manifest hashing\", () =>\n\t\t\t\tBuildAndWriteManifest(\n\t\t\t\t\tgenerationDocument,\n\t\t\t\t\toutputRoot,\n\t\t\t\t\tgeneratedSourcePaths,\n\t\t\t\t\tfiles,\n\t\t\t\t\tdiagnostics,\n\t\t\t\t\tbackups,\n\t\t\t\t\tnewFiles,\n\t\t\t\t\tcancellationToken ),\n\t\t\t\tcancellationToken );\n\t\t\tLogStage( \"manifest hashing and write\", \"worker\" );\n\t\t\tgenerationDocument.Manifest = manifest;\n\t\t\tdocument.Manifest = manifest;\n\t\t\tprogress?.Invoke( new GenerationProgress( \"Complete\", \"Generation completed\" ) );\n\n\t\t\treturn new GenerationResult\n\t\t\t{\n\t\t\t\tSuccess = true,\n\t\t\t\tOutputFolder = outputRoot,\n\t\t\t\tValidation = validation,\n\t\t\t\tDiagnostics = diagnostics,\n\t\t\t\tGeneratedFiles = generatedSourcePaths\n\t\t\t\t\t.Append( ManifestFile )\n\t\t\t\t\t.OrderBy( x => x )\n\t\t\t\t\t.ToList()\n\t\t\t};\n\t\t}\n\t\tcatch ( OperationCanceledException )\n\t\t{\n\t\t\tRestoreGeneratedFiles( newFiles, backups );\n\t\t\tLog.Info( \"[Weapon Animator] generation cancelled; owned outputs were restored.\" );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Warning,\n\t\t\t\t\"generation.cancelled\",\n\t\t\t\t\"Generation was cancelled and the previous owned outputs were restored.\" ) );\n\t\t\treturn new GenerationResult\n\t\t\t{\n\t\t\t\tSuccess = false,\n\t\t\t\tCancelled = true,\n\t\t\t\tOutputFolder = outputRoot,\n\t\t\t\tValidation = validation,\n\t\t\t\tDiagnostics = diagnostics\n\t\t\t};\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Error( $\"[Weapon Animator] generation rolled back: {ex}\" );\n\t\t\tRestoreGeneratedFiles( newFiles, backups );\n\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\"generation.rollback\",\n\t\t\t\t$\"Generation failed and owned outputs were restored: {ex.Message}\" ) );\n\t\t\treturn new GenerationResult\n\t\t\t{\n\t\t\t\tSuccess = false,\n\t\t\t\tOutputFolder = outputRoot,\n\t\t\t\tValidation = validation,\n\t\t\t\tDiagnostics = diagnostics\n\t\t\t};\n\t\t}\n\t}\n\n\tprivate static void WriteTextureCopies(\n\t\tstring root,\n\t\tIEnumerable<WeaponMaterialPipeline.GeneratedTextureCopy> copies,\n\t\tDictionary<string, byte[]> backups,\n\t\tHashSet<string> newFiles,\n\t\tIReadOnlySet<string> previouslyOwnedFiles,\n\t\tCancellationToken cancellationToken = default )\n\t{\n\t\tforeach ( var copy in copies.OrderBy(\n\t\t\tcopy => copy.RelativePath,\n\t\t\tStringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tif ( !File.Exists( copy.SourceAbsolute ) )\n\t\t\t\tthrow new FileNotFoundException(\n\t\t\t\t\t$\"Texture source for '{copy.RelativePath}' no longer exists.\",\n\t\t\t\t\tcopy.SourceAbsolute );\n\n\t\t\tvar absolute = Path.Combine( root, copy.RelativePath );\n\t\t\tvar directory = Path.GetDirectoryName( absolute );\n\t\t\tif ( !string.IsNullOrWhiteSpace( directory ) )\n\t\t\t\tDirectory.CreateDirectory( directory );\n\t\t\tif ( File.Exists( absolute ) )\n\t\t\t\tbackups[absolute] = File.ReadAllBytes( absolute );\n\t\t\telse if ( ShouldDeleteCreatedFileOnRollback(\n\t\t\t\tcopy.RelativePath,\n\t\t\t\tpreviouslyOwnedFiles.Contains( copy.RelativePath ) ) )\n\t\t\t\tnewFiles.Add( absolute );\n\n\t\t\tFile.Copy( copy.SourceAbsolute, absolute, true );\n\t\t}\n\t}\n\n\tprivate static void WriteFiles(\n\t\tstring root,\n\t\tIReadOnlyDictionary<string, string> files,\n\t\tDictionary<string, byte[]>? backups = null,\n\t\tHashSet<string>? newFiles = null,\n\t\tIReadOnlySet<string>? previouslyOwnedFiles = null,\n\t\tCancellationToken cancellationToken = default )\n\t{\n\t\tDirectory.CreateDirectory( root );\n\n\t\t// Sources before the .vmdl/.vanmgrph/.prefab that consume them, so the asset system never\n\t\t// sees a model whose animation files have not landed yet.\n\t\tforeach ( var name in OrderForWrite( files.Keys ) )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tvar absolute = Path.Combine( root, name );\n\t\t\tvar directory = Path.GetDirectoryName( absolute );\n\t\t\tif ( !string.IsNullOrWhiteSpace( directory ) )\n\t\t\t\tDirectory.CreateDirectory( directory );\n\t\t\tif ( backups is not null && File.Exists( absolute ) )\n\t\t\t\tbackups[absolute] = File.ReadAllBytes( absolute );\n\t\t\telse if ( newFiles is not null\n\t\t\t\t&& !File.Exists( absolute )\n\t\t\t\t&& backups?.ContainsKey( absolute ) != true )\n\t\t\t{\n\t\t\t\tif ( ShouldDeleteCreatedFileOnRollback(\n\t\t\t\t\tname,\n\t\t\t\t\tpreviouslyOwnedFiles?.Contains( name ) == true ) )\n\t\t\t\t\tnewFiles.Add( absolute );\n\t\t\t}\n\n\t\t\t// Deliberately not an atomic write-and-rename. Replacing the file makes the engine's\n\t\t\t// directory watcher report it as removed and re-added, and a dependency sampled during\n\t\t\t// that gap is cached as \"file stopped existing\" \u2014 which recompiles the model forever.\n\t\t\t// Truncating in place only ever looks like a modification.\n\t\t\tFile.WriteAllText( absolute, files[name], new UTF8Encoding( false ) );\n\t\t}\n\t}\n\n\tinternal static void WriteTextSourcesForTests(\n\t\tstring root,\n\t\tIReadOnlyDictionary<string, string> files ) =>\n\t\tWriteFiles( root, files );\n\n\tinternal static IEnumerable<string> OrderForWrite( IEnumerable<string> paths ) =>\n\t\tpaths\n\t\t\t.OrderBy( ConsumerWriteRank )\n\t\t\t.ThenBy( path => path, StringComparer.OrdinalIgnoreCase );\n\n\tprivate static int ConsumerWriteRank( string path )\n\t{\n\t\tvar extension = Path.GetExtension( path ).ToLowerInvariant();\n\t\tif ( !IsCompiledConsumer( path ) )\n\t\t\treturn 0;\n\t\tif ( IsSourceAdapter( path ) )\n\t\t\treturn 3;\n\t\tif ( IsBootstrapHost( path ) )\n\t\t\treturn 4;\n\t\treturn extension switch\n\t\t{\n\t\t\t\".vtex\" => 1,\n\t\t\t\".vmat\" => 2,\n\t\t\t\".vanmgrph\" => 5,\n\t\t\t\".vmdl\" => 6,\n\t\t\t\".prefab\" => 7,\n\t\t\t_ => 8\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// Extensions the asset system compiles and then tracks dependencies for. Removing one of\n\t/// these before the sources it consumes keeps the engine from retrying a compile against\n\t/// files that are about to disappear.\n\t/// </summary>\n\tprivate static readonly string[] CompiledConsumerExtensions =\n\t\t[\".vtex\", \".vmat\", \".vmdl\", \".vanmgrph\", \".prefab\"];\n\n\tprivate static bool IsCompiledConsumer( string path ) =>\n\t\tCompiledConsumerExtensions.Contains(\n\t\t\tPath.GetExtension( path ),\n\t\t\tStringComparer.OrdinalIgnoreCase );\n\n\tinternal static bool ShouldDeleteCreatedFileOnRollback(\n\t\tstring relativePath,\n\t\tbool previouslyOwned ) =>\n\t\t!previouslyOwned\n\t\t|| IsCompiledConsumer( relativePath );\n\n\tprivate static int ConsumerRemovalRank( string path ) =>\n\t\tIsSourceAdapter( path )\n\t\t\t? 3\n\t\t\t: IsBootstrapHost( path )\n\t\t\t\t? 4\n\t\t\t: Path.GetExtension( path ).ToLowerInvariant() switch\n\t\t\t{\n\t\t\t\t\".prefab\" => 7,\n\t\t\t\t\".vmdl\" => 6,\n\t\t\t\t\".vanmgrph\" => 5,\n\t\t\t\t\".vmat\" => 2,\n\t\t\t\t\".vtex\" => 1,\n\t\t\t\t_ => 0\n\t\t\t};\n\n\tprivate static bool IsSourceAdapter( string path ) =>\n\t\tpath.EndsWith( \"_source_adapter.vmdl\", StringComparison.OrdinalIgnoreCase );\n\n\tprivate static bool IsBootstrapHost( string path ) =>\n\t\tpath.EndsWith( \"_host_bootstrap.vmdl\", StringComparison.OrdinalIgnoreCase )\n\t\t|| path.EndsWith( \"_vm_bootstrap.vmdl\", StringComparison.OrdinalIgnoreCase );\n\n\tinternal static IEnumerable<string> OrderForRemoval( IEnumerable<string> absolutePaths ) =>\n\t\tabsolutePaths\n\t\t\t.OrderByDescending( ConsumerRemovalRank )\n\t\t\t.ThenBy( path => path, StringComparer.OrdinalIgnoreCase );\n\n\t/// <summary>\n\t/// Drop compiled consumers before rewriting their source set. This clears dependency\n\t/// metadata inherited from older generators without ever removing a DMX dependency.\n\t/// </summary>\n\tprivate static void PrepareCompiledConsumersForRewrite(\n\t\tstring outputRoot,\n\t\tIEnumerable<string> relativePaths,\n\t\tDictionary<string, byte[]> backups,\n\t\tHashSet<string> newFiles )\n\t{\n\t\tvar consumers = relativePaths\n\t\t\t.Where( IsCompiledConsumer )\n\t\t\t.Select( path => Path.Combine( outputRoot, path ) )\n\t\t\t.ToArray();\n\t\tforeach ( var absolute in OrderForRemoval( consumers ) )\n\t\t{\n\t\t\tif ( File.Exists( absolute ) )\n\t\t\t\tbackups.TryAdd( absolute, File.ReadAllBytes( absolute ) );\n\t\t\telse\n\t\t\t\tnewFiles.Add( absolute );\n\n\t\t\tvar registered = AssetSystem.FindByPath( absolute );\n\t\t\tif ( registered is not null )\n\t\t\t{\n\t\t\t\tLog.Info(\n\t\t\t\t\t$\"[Weapon Animator] resetting registered consumer '{registered.Path}' \"\n\t\t\t\t\t+ \"before generation.\" );\n\t\t\t\tregistered.Delete();\n\t\t\t}\n\n\t\t\t// Asset.Delete normally removes both files. Explicit cleanup also handles a stale\n\t\t\t// registry entry whose source path has already disappeared.\n\t\t\tforeach ( var path in new[] { absolute, absolute + \"_c\" } )\n\t\t\t{\n\t\t\t\tif ( File.Exists( path ) )\n\t\t\t\t\tFile.Delete( path );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static void VerifyGeneratedSources(\n\t\tstring outputRoot,\n\t\tIEnumerable<string> relativePaths )\n\t{\n\t\tvar missing = relativePaths\n\t\t\t.Where( path => !File.Exists( Path.Combine( outputRoot, path ) ) )\n\t\t\t.ToArray();\n\t\tif ( missing.Length > 0 )\n\t\t{\n\t\t\tthrow new IOException(\n\t\t\t\t$\"Generated source set is incomplete: {string.Join( \", \", missing )}.\" );\n\t\t}\n\n\t\tLog.Info(\n\t\t\t$\"[Weapon Animator] verified {relativePaths.Count()} persistent generation sources \"\n\t\t\t+ $\"under '{outputRoot}'.\" );\n\t}\n\n\tprivate static async Task RegisterGeneratedDependenciesAsync(\n\t\tstring outputRoot,\n\t\tIEnumerable<string> relativePaths,\n\t\tAction<GenerationProgress>? progress,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar sources = relativePaths\n\t\t\t.Where( path => !IsCompiledConsumer( path ) )\n\t\t\t.Select( path => Path.Combine( outputRoot, path ) )\n\t\t\t.ToArray();\n\t\tfor ( var index = 0; index < sources.Length; index++ )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tvar absolute = sources[index];\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\"Register\",\n\t\t\t\tPath.GetFileName( absolute ),\n\t\t\t\tindex + 1,\n\t\t\t\tsources.Length ) );\n\t\t\tvar timer = Stopwatch.StartNew();\n\t\t\tvar asset = AssetSystem.RegisterFile( absolute );\n\t\t\tLog.Info(\n\t\t\t\t$\"[Weapon Animator] dependency registration '{Path.GetFileName( absolute )}' \"\n\t\t\t\t+ $\"took {timer.ElapsedMilliseconds} ms \"\n\t\t\t\t+ $\"({new FileInfo( absolute ).Length} bytes).\" );\n\t\t\tif ( asset is null || asset.IsDeleted || !asset.HasSourceFile )\n\t\t\t{\n\t\t\t\tthrow new IOException(\n\t\t\t\t\t$\"The asset system did not retain generated dependency '{absolute}'.\" );\n\t\t\t}\n\n\t\t\t// RegisterFile can synchronously inspect a large DMX. Yield between dependencies so\n\t\t\t// repaint, progress, and cancellation are serviced before the next inspection.\n\t\t\tawait GameTask.Yield();\n\t\t}\n\n\t\tLog.Info(\n\t\t\t$\"[Weapon Animator] registered {sources.Length} persistent source dependencies \"\n\t\t\t+ \"before compiling their consumers.\" );\n\t}\n\n\tinternal static void DeleteGeneratedFiles( IEnumerable<string> absolutePaths )\n\t{\n\t\tforeach ( var path in OrderForRemoval( absolutePaths ).ToList() )\n\t\t{\n\t\t\tvar deletedByAssetSystem = false;\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar asset = AssetSystem.FindByPath( path );\n\t\t\t\tif ( asset is not null )\n\t\t\t\t{\n\t\t\t\t\tLog.Info(\n\t\t\t\t\t\t$\"[Weapon Animator] unregistering generated asset '{asset.Path}' \"\n\t\t\t\t\t\t+ $\"before removing '{path}'.\" );\n\t\t\t\t\tasset.Delete();\n\t\t\t\t\tdeletedByAssetSystem = true;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch ( Exception ex )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\"[Weapon Animator] asset-aware removal failed for '{path}': {ex.Message}\" );\n\t\t\t}\n\n\t\t\t// Uncompiled DMX sources and verification runs have no Asset entry.\n\t\t\tforeach ( var target in new[] { path, path + \"_c\" } )\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tif ( File.Exists( target ) )\n\t\t\t\t\t\tFile.Delete( target );\n\t\t\t\t}\n\t\t\t\tcatch ( Exception ex )\n\t\t\t\t{\n\t\t\t\t\tLog.Warning( $\"[Weapon Animator] could not remove '{target}': {ex.Message}\" );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( deletedByAssetSystem )\n\t\t\t\tLog.Info( $\"[Weapon Animator] asset registry removal completed for '{path}'.\" );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Generator versions before 1.3.0 compiled into a staging folder and then deleted it,\n\t/// which left the asset system recompiling assets whose sources were gone. Clear anything\n\t/// those runs left behind, dependants first.\n\t/// </summary>\n\tprivate static void DiscardAbandonedStage( WeaponAnimationDocument document )\n\t{\n\t\ttry\n\t\t{\n\t\t\tvar stageRoot = Path.Combine(\n\t\t\t\tWeaponSourceImporter.GetPreviewCacheRoot( document.DocumentId ),\n\t\t\t\t\"generation-stage\" );\n\t\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\t\tvar stalePaths = new HashSet<string>( StringComparer.OrdinalIgnoreCase )\n\t\t\t{\n\t\t\t\tPath.Combine( stageRoot, $\"{slug}_host.vmdl\" ),\n\t\t\t\tPath.Combine( stageRoot, $\"{slug}_vm.vmdl\" ),\n\t\t\t\tPath.Combine( stageRoot, $\"{slug}_host_bootstrap.vmdl\" ),\n\t\t\t\tPath.Combine( stageRoot, $\"{slug}_vm_bootstrap.vmdl\" ),\n\t\t\t\tPath.Combine( stageRoot, $\"{slug}_source.vmdl\" ),\n\t\t\t\tPath.Combine( stageRoot, $\"{slug}.vanmgrph\" ),\n\t\t\t\tPath.Combine( stageRoot, $\"v_{slug}.prefab\" ),\n\t\t\t\tPath.Combine( stageRoot, $\"{slug}_host_reference.dmx\" )\n\t\t\t};\n\t\t\tforeach ( var clip in document.Clips )\n\t\t\t{\n\t\t\t\tvar stem = $\"{slug}_{WeaponAnimationNames.SequenceName( clip )}\";\n\t\t\t\tstalePaths.Add( Path.Combine( stageRoot, $\"{stem}.smd\" ) );\n\t\t\t\tstalePaths.Add( Path.Combine( stageRoot, $\"{stem}.dmx\" ) );\n\t\t\t}\n\t\t\tvar normalizedStageRoot = Path.GetFullPath( stageRoot )\n\t\t\t\t.TrimEnd( Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar )\n\t\t\t\t+ Path.DirectorySeparatorChar;\n\t\t\tvar registeredStagePaths = AssetSystem.All\n\t\t\t\t.Where( asset => !string.IsNullOrWhiteSpace( asset.AbsolutePath ) )\n\t\t\t\t.Select( asset => Path.GetFullPath( asset.AbsolutePath ) )\n\t\t\t\t.Where( path => path.StartsWith(\n\t\t\t\t\tnormalizedStageRoot,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t.ToArray();\n\t\t\tstalePaths.UnionWith( registeredStagePaths );\n\t\t\tvar stageExisted = Directory.Exists( stageRoot );\n\t\t\tif ( Directory.Exists( stageRoot ) )\n\t\t\t{\n\t\t\t\tstalePaths.UnionWith(\n\t\t\t\t\tDirectory.GetFiles(\n\t\t\t\t\t\tstageRoot,\n\t\t\t\t\t\t\"*\",\n\t\t\t\t\t\tSearchOption.AllDirectories ) );\n\t\t\t}\n\n\t\t\t// Exact legacy paths are included even when their source files are already gone. This\n\t\t\t// lets Asset.Delete clear the stale registry entries that trigger on-demand retries.\n\t\t\tDeleteGeneratedFiles( stalePaths );\n\t\t\tif ( Directory.Exists( stageRoot ) )\n\t\t\t\tDirectory.Delete( stageRoot, true );\n\t\t\tif ( stageExisted || registeredStagePaths.Length > 0 )\n\t\t\t\tLog.Info( $\"[Weapon Animator] removed abandoned generation stage '{stageRoot}'.\" );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Warning( $\"[Weapon Animator] could not clear the abandoned generation stage: {ex.Message}\" );\n\t\t}\n\t}\n\n\tprivate static void RemoveObsoleteOwnedFiles(\n\t\tWeaponAnimationDocument document,\n\t\tstring outputRoot,\n\t\tIEnumerable<string> generatedFiles,\n\t\tList<GenerationDiagnostic> diagnostics )\n\t{\n\t\tvar retained = generatedFiles.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tvar obsolete = document.Manifest.Files\n\t\t\t.Select( x => x.RelativePath )\n\t\t\t.Where( x => !retained.Contains( x ) )\n\t\t\t.Select( x => Path.Combine(\n\t\t\t\toutputRoot,\n\t\t\t\tx.Replace( '/', Path.DirectorySeparatorChar ) ) )\n\t\t\t.ToArray();\n\t\tif ( obsolete.Length == 0 )\n\t\t\treturn;\n\n\t\tDeleteGeneratedFiles( obsolete );\n\t\tforeach ( var path in obsolete )\n\t\t{\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Info,\n\t\t\t\t\"ownership.obsolete_removed\",\n\t\t\t\t$\"Removed obsolete generated file '{Path.GetFileName( path )}'.\",\n\t\t\t\tpath ) );\n\t\t}\n\t}\n\n\tpublic static string GetOutputFolder( WeaponAnimationDocument document ) =>\n\t\tResolveOutputRoot( document );\n\n\tinternal static Dictionary<string, string> BuildFiles(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tstring relativeRoot,\n\t\tAction<GenerationProgress>? progress = null,\n\t\tCancellationToken cancellationToken = default )\n\t{\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\tvar preparedClips = BuildClipSources(\n\t\t\tdocument,\n\t\t\tskeleton,\n\t\t\tprogress,\n\t\t\tcancellationToken );\n\t\treturn AssembleFiles( document, skeleton, relativeRoot, preparedClips );\n\t}\n\n\tprivate static async Task<Dictionary<string, string>> BuildFilesResponsiveAsync(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tstring relativeRoot,\n\t\tAction<GenerationProgress>? progress,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar orderedClips = GeneratedClips( document )\n\t\t\t.OrderBy( clip => clip.Name )\n\t\t\t.ToArray();\n\t\tvar preparedClips = new List<PreparedClipSource>( orderedClips.Length );\n\t\tfor ( var clipIndex = 0; clipIndex < orderedClips.Length; clipIndex++ )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tvar clip = orderedClips[clipIndex];\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\"Sequences\",\n\t\t\t\t$\"Sampling {clip.Name}\",\n\t\t\t\tclipIndex + 1,\n\t\t\t\torderedClips.Length ) );\n\t\t\tvar source = await RunResponsiveWorkerAsync( $\"sequence {clip.Name}\", () =>\n\t\t\t\tDmxWriter.WriteAnimation(\n\t\t\t\t\tdocument,\n\t\t\t\t\tskeleton,\n\t\t\t\t\tclip,\n\t\t\t\t\tcancellationToken ),\n\t\t\t\tcancellationToken );\n\t\t\tpreparedClips.Add( new PreparedClipSource( clip, source ) );\n\t\t}\n\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\treturn AssembleFiles( document, skeleton, relativeRoot, preparedClips );\n\t}\n\n\tprivate static IReadOnlyList<PreparedClipSource> BuildClipSources(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tAction<GenerationProgress>? progress,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar orderedClips = GeneratedClips( document )\n\t\t\t.OrderBy( clip => clip.Name )\n\t\t\t.ToArray();\n\t\tvar preparedClips = new List<PreparedClipSource>( orderedClips.Length );\n\t\tfor ( var clipIndex = 0; clipIndex < orderedClips.Length; clipIndex++ )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tvar clip = orderedClips[clipIndex];\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\"Sequences\",\n\t\t\t\t$\"Sampling {clip.Name}\",\n\t\t\t\tclipIndex + 1,\n\t\t\t\torderedClips.Length ) );\n\t\t\tpreparedClips.Add( new PreparedClipSource(\n\t\t\t\tclip,\n\t\t\t\tDmxWriter.WriteAnimation(\n\t\t\t\t\tdocument,\n\t\t\t\t\tskeleton,\n\t\t\t\t\tclip,\n\t\t\t\t\tcancellationToken ) ) );\n\t\t}\n\t\treturn preparedClips;\n\t}\n\n\tprivate static Dictionary<string, string> AssembleFiles(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tstring relativeRoot,\n\t\tIReadOnlyList<PreparedClipSource> preparedClips )\n\t{\n\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\tvar files = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar referenceName = $\"{slug}_host_reference.dmx\";\n\t\tvar bootstrapHostName = $\"{slug}_vm_bootstrap.vmdl\";\n\t\tvar hostName = $\"{slug}_vm.vmdl\";\n\t\tvar graphName = $\"{slug}.vanmgrph\";\n\t\tvar prefabName = $\"v_{slug}.prefab\";\n\t\tfiles[referenceName] = DmxWriter.WriteReference( skeleton );\n\t\tforeach ( var materialFile in WeaponMaterialPipeline.BuildOutputTextFiles(\n\t\t\tdocument,\n\t\t\trelativeRoot ) )\n\t\t{\n\t\t\tfiles[materialFile.Key] = materialFile.Value;\n\t\t}\n\n\t\tvar clipSources = new List<(WeaponAnimationClip Clip, string Source)>();\n\t\tforeach ( var preparedClip in preparedClips )\n\t\t{\n\t\t\tvar clip = preparedClip.Clip;\n\t\t\tvar clipName =\n\t\t\t\t$\"{slug}_sequence_{WeaponAnimationNames.SequenceName( clip )}.dmx\";\n\t\t\tfiles[clipName] = preparedClip.Source;\n\t\t\tclipSources.Add( (clip, $\"{relativeRoot}/{clipName}\") );\n\t\t}\n\n\t\tvar graphPath = document.Output.GenerateGraph && document.Graph.GenerateGraph\n\t\t\t? $\"{relativeRoot}/{graphName}\"\n\t\t\t: \"\";\n\t\tvar placement = WeaponAnimationMath.Compose(\n\t\t\tdocument.Calibration.PhysicalTransform,\n\t\t\tdocument.Calibration.FramingTransform );\n\t\tvar excludedBranches = ExcludedBranchRoots( document ).ToArray();\n\t\tvar materialRemaps = WeaponMaterialPipeline.OutputRemaps(\n\t\t\tdocument,\n\t\t\trelativeRoot );\n\t\tHostWeaponMesh? weaponMesh = null;\n\t\tvar baseModelPath = \"\";\n\t\tif ( IsVmdlSource( document ) )\n\t\t{\n\t\t\tvar adapterName = $\"{slug}_source_adapter.vmdl\";\n\t\t\tvar sourceAbsolute = ResolveSourceAbsolutePath( document.Source.SourcePath );\n\t\t\tif ( !File.Exists( sourceAbsolute ) )\n\t\t\t\tthrow new FileNotFoundException(\n\t\t\t\t\t\"The imported VMDL source no longer exists.\",\n\t\t\t\t\tsourceAbsolute );\n\t\t\tfiles[adapterName] = ModelDocWriter.WriteVmdlSourceAdapter(\n\t\t\t\tFile.ReadAllText( sourceAbsolute ),\n\t\t\t\tdocument.Source.SourceRootBoneName,\n\t\t\t\texcludedBranches,\n\t\t\t\tplacement );\n\t\t\tbaseModelPath = $\"{relativeRoot}/{adapterName}\";\n\t\t}\n\t\telse\n\t\t{\n\t\t\tweaponMesh = new HostWeaponMesh(\n\t\t\t\tResolveEmbeddableSourcePath( document ),\n\t\t\t\tdocument.Source.SourceRootBoneName,\n\t\t\t\tplacement,\n\t\t\t\texcludedBranches,\n\t\t\t\tmaterialRemaps );\n\t\t}\n\t\tvar hostSource = ModelDocWriter.WriteHost(\n\t\t\t$\"{relativeRoot}/{referenceName}\",\n\t\t\tclipSources,\n\t\t\tgraphPath,\n\t\t\tskeleton.Bones.Select( bone => bone.Name ),\n\t\t\tweaponMesh,\n\t\t\tBuildHostAttachments( document, skeleton ),\n\t\t\tbaseModelPath,\n\t\t\tmaterialRemaps );\n\t\tfiles[hostName] = hostSource;\n\n\t\tif ( document.Output.GenerateGraph && document.Graph.GenerateGraph )\n\t\t{\n\t\t\t// The graph previews a permanent graph-free sibling, breaking the otherwise circular\n\t\t\t// host -> graph -> preview-host compile dependency.\n\t\t\tfiles[bootstrapHostName] = ModelDocWriter.WriteHost(\n\t\t\t\t$\"{relativeRoot}/{referenceName}\",\n\t\t\t\tclipSources,\n\t\t\t\t\"\",\n\t\t\t\tskeleton.Bones.Select( bone => bone.Name ),\n\t\t\t\tweaponMesh,\n\t\t\t\tBuildHostAttachments( document, skeleton ),\n\t\t\t\tbaseModelPath,\n\t\t\t\tmaterialRemaps );\n\t\t\tfiles[graphName] = AnimGraphWriter.Write(\n\t\t\t\tdocument,\n\t\t\t\t$\"{relativeRoot}/{bootstrapHostName}\" );\n\t\t}\n\n\t\tif ( document.Output.GeneratePrefab )\n\t\t\tfiles[prefabName] = PrefabWriter.Write(\n\t\t\t\tdocument,\n\t\t\t\t$\"{relativeRoot}/{hostName}\" );\n\n\t\treturn files;\n\t}\n\n\tprivate sealed record PreparedClipSource(\n\t\tWeaponAnimationClip Clip,\n\t\tstring Source );\n\n\tprivate static async Task<T> RunResponsiveWorkerAsync<T>(\n\t\tstring stage,\n\t\tFunc<T> work,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar worker = GameTask.RunInThreadAsync( work );\n\t\tvar timer = Stopwatch.StartNew();\n\t\tvar nextHeartbeat = 2000L;\n\t\twhile ( !worker.IsCompleted )\n\t\t{\n\t\t\t// Explicitly return to the editor task loop while the worker owns CPU-heavy text work.\n\t\t\tawait GameTask.DelayRealtime( 16 );\n\t\t\tif ( timer.ElapsedMilliseconds < nextHeartbeat )\n\t\t\t\tcontinue;\n\n\t\t\tLog.Info(\n\t\t\t\t$\"[Weapon Animator] worker heartbeat: '{stage}' is still running after \"\n\t\t\t\t+ $\"{timer.ElapsedMilliseconds} ms; editor thread \"\n\t\t\t\t+ $\"{Environment.CurrentManagedThreadId} is pumping.\" );\n\t\t\tnextHeartbeat += 2000;\n\t\t}\n\n\t\t// Managed loops observe cancellation internally. Native calls cannot be interrupted, so\n\t\t// await their return before cancellation is allowed to start rollback.\n\t\tvar result = await worker;\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\treturn result;\n\t}\n\n\tprivate static string ResolveEmbeddableSourcePath( WeaponAnimationDocument document )\n\t{\n\t\tvar extension = Path.GetExtension( document.Source.SourcePath );\n\t\tif ( WeaponSourceFormatSupport.CanGenerate( document.Source.SourcePath )\n\t\t\t&& !extension.Equals( \".vmdl\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn document.Source.SourcePath;\n\n\t\tthrow new InvalidOperationException(\n\t\t\t$\"The standard single-renderer viewmodel currently needs an FBX, DMX, OBJ, or VMDL render source; \"\n\t\t\t+ $\"'{extension}' cannot be embedded by ModelDoc.\" );\n\t}\n\n\tinternal static IReadOnlyList<WeaponAnimationClip> GeneratedClips(\n\t\tWeaponAnimationDocument document ) =>\n\t\tdocument.Clips\n\t\t\t.Where( clip => clip.Readiness != ClipReadiness.NotStarted )\n\t\t\t.ToArray();\n\n\tprivate static bool IsVmdlSource( WeaponAnimationDocument document ) =>\n\t\tPath.GetExtension( document.Source.SourcePath )\n\t\t\t.Equals( \".vmdl\", StringComparison.OrdinalIgnoreCase );\n\n\tprivate static string ResolveSourceAbsolutePath( string sourcePath ) =>\n\t\tPath.IsPathRooted( sourcePath )\n\t\t\t? Path.GetFullPath( sourcePath )\n\t\t\t: Path.GetFullPath( Path.Combine(\n\t\t\t\tWeaponSourceImporter.GetContentRoot(),\n\t\t\t\tsourcePath.TrimStart( '/', '\\\\' ) ) );\n\n\tprivate static IEnumerable<HostAttachment> BuildHostAttachments(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton )\n\t{\n\t\tvar sourceRoot = document.Rig.FindBone( document.Rig.SourceSkeletonRootId );\n\t\tvar compilerModel = skeleton.BuildCompilerBindModelTransforms();\n\t\tvar placement = WeaponAnimationMath.Compose(\n\t\t\tdocument.Calibration.PhysicalTransform,\n\t\t\tdocument.Calibration.FramingTransform );\n\t\tforeach ( var anchor in document.Calibration.Anchors.Where( x =>\n\t\t\tx.Kind is AnchorKind.Muzzle or AnchorKind.Eject or AnchorKind.Custom ) )\n\t\t{\n\t\t\tvar parent = document.Rig.FindBone( anchor.BoneName ) ?? sourceRoot;\n\t\t\tif ( parent is null )\n\t\t\t\tcontinue;\n\n\t\t\tvar parentName = parent.Id.Equals(\n\t\t\t\tdocument.Rig.SourceSkeletonRootId,\n\t\t\t\tStringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t? \"weapon_root\"\n\t\t\t\t\t: parent.Name;\n\t\t\tif ( !compilerModel.TryGetValue( parentName, out var compiledParent ) )\n\t\t\t\tcontinue;\n\n\t\t\tvar anchorModelPosition = placement.PointToWorld( anchor.LocalPosition );\n\t\t\tvar anchorModelRotation = placement.Rotation * anchor.LocalRotation;\n\t\t\tyield return new HostAttachment(\n\t\t\t\tWeaponAnimationNames.AttachmentName( anchor ),\n\t\t\t\tparentName,\n\t\t\t\tcompiledParent.PointToLocal( anchorModelPosition ),\n\t\t\t\tcompiledParent.Rotation.Inverse * anchorModelRotation );\n\t\t}\n\t}\n\n\tprivate static IEnumerable<string> ExcludedBranchRoots(\n\t\tWeaponAnimationDocument document )\n\t{\n\t\tforeach ( var bone in document.Rig.Bones.Where( x =>\n\t\t\tx.Inclusion == WeaponBoneInclusion.Excluded ) )\n\t\t{\n\t\t\tvar parent = document.Rig.FindBone( bone.ParentId );\n\t\t\tif ( parent is null || parent.Inclusion != WeaponBoneInclusion.Excluded )\n\t\t\t\tyield return string.IsNullOrWhiteSpace( bone.OriginalName )\n\t\t\t\t\t? bone.Name\n\t\t\t\t\t: bone.OriginalName;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Seconds to let a single generated asset finish compiling before treating it as failed.\n\t/// </summary>\n\tprivate const float CompileTimeoutSeconds = 120.0f;\n\tprivate const float HostReloadTimeoutSeconds = 20.0f;\n\n\t/// <summary>\n\t/// Asset compilation is main-thread-only. The resource compiler may hold the editor while the\n\t/// request runs, then this method polls until the replacement asset is live.\n\t/// </summary>\n\tinternal static async Task<bool> WaitForCompileAsync(\n\t\tAsset asset,\n\t\tstring sourceAbsolute,\n\t\tCancellationToken cancellationToken = default )\n\t{\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\tvar queued = asset.Compile( true );\n\t\tLog.Info(\n\t\t\t$\"[Weapon Animator] compile request for '{asset.Path}': queued={queued}, \"\n\t\t\t+ $\"deleted={asset.IsDeleted}, canRecompile={asset.CanRecompile}, \"\n\t\t\t+ $\"hasSource={asset.HasSourceFile}.\" );\n\n\t\tvar deadline = DateTime.UtcNow.AddSeconds( CompileTimeoutSeconds );\n\t\tvar nextProgressLog = DateTime.UtcNow.AddSeconds( 5 );\n\t\tvar retriedLiveAsset = false;\n\t\twhile ( true )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\t// Asset.Delete followed by RegisterFile can leave callers holding the retired managed\n\t\t\t// wrapper while the directory watcher has already created and compiled a replacement.\n\t\t\tvar current = AssetSystem.FindByPath( sourceAbsolute ) ?? asset;\n\t\t\tvar compiledAbsolute = FreshCompiledArtifact( current, sourceAbsolute );\n\t\t\tif ( !string.IsNullOrWhiteSpace( compiledAbsolute ) )\n\t\t\t{\n\t\t\t\tif ( !current.IsCompiledAndUpToDate || !current.HasCompiledFile )\n\t\t\t\t{\n\t\t\t\t\tLog.Info(\n\t\t\t\t\t\t$\"[Weapon Animator] accepted fresh compiled artifact '{compiledAbsolute}' \"\n\t\t\t\t\t\t+ $\"while the managed asset flags for '{current.Path}' caught up.\" );\n\t\t\t\t}\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tif ( current.IsCompileFailed )\n\t\t\t\treturn false;\n\t\t\tif ( current.IsCompiledAndUpToDate && current.HasCompiledFile )\n\t\t\t{\n\t\t\t\tLog.Error(\n\t\t\t\t\t$\"[Weapon Animator] '{current.Path}' reports compiled but no fresh artifact \"\n\t\t\t\t\t+ $\"exists for '{sourceAbsolute}'.\" );\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tif ( !retriedLiveAsset\n\t\t\t\t&& !ReferenceEquals( current, asset )\n\t\t\t\t&& current.CanRecompile )\n\t\t\t{\n\t\t\t\tretriedLiveAsset = true;\n\t\t\t\tvar liveQueued = current.Compile( true );\n\t\t\t\tLog.Info(\n\t\t\t\t\t$\"[Weapon Animator] retried compile through the live asset entry \"\n\t\t\t\t\t+ $\"'{current.Path}': queued={liveQueued}.\" );\n\t\t\t}\n\t\t\tif ( DateTime.UtcNow > deadline )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\"[Weapon Animator] '{current.Path}' did not finish compiling within \"\n\t\t\t\t\t+ $\"{CompileTimeoutSeconds:0} seconds.\" );\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif ( DateTime.UtcNow >= nextProgressLog )\n\t\t\t{\n\t\t\t\tLog.Info(\n\t\t\t\t\t$\"[Weapon Animator] waiting for '{current.Path}': \"\n\t\t\t\t\t+ $\"compiled={current.IsCompiled}, upToDate={current.IsCompiledAndUpToDate}, \"\n\t\t\t\t\t+ $\"hasCompiledFile={current.HasCompiledFile}, deleted={current.IsDeleted}, \"\n\t\t\t\t\t+ $\"canRecompile={current.CanRecompile}, hasSource={current.HasSourceFile}, \"\n\t\t\t\t\t+ $\"sourceExists={File.Exists( sourceAbsolute )}.\" );\n\t\t\t\tnextProgressLog = DateTime.UtcNow.AddSeconds( 5 );\n\t\t\t}\n\n\t\t\tawait Task.Delay( 16, cancellationToken );\n\t\t}\n\t}\n\n\tprivate static string FreshCompiledArtifact(\n\t\tAsset asset,\n\t\tstring sourceAbsolute )\n\t{\n\t\tvar candidates = new HashSet<string>( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\tsourceAbsolute + \"_c\"\n\t\t};\n\t\ttry\n\t\t{\n\t\t\tvar reported = asset.GetCompiledFile( true );\n\t\t\tif ( !string.IsNullOrWhiteSpace( reported ) )\n\t\t\t\tcandidates.Add( reported );\n\t\t}\n\t\tcatch\n\t\t{\n\t\t\t// A retired asset wrapper can throw while its replacement is registered.\n\t\t}\n\n\t\treturn candidates.FirstOrDefault( compiled =>\n\t\t\tIsFreshCompiledArtifact( sourceAbsolute, compiled ) ) ?? \"\";\n\t}\n\n\tinternal static bool IsFreshCompiledArtifact(\n\t\tstring sourceAbsolute,\n\t\tstring compiledAbsolute )\n\t{\n\t\tif ( !File.Exists( sourceAbsolute ) || !File.Exists( compiledAbsolute ) )\n\t\t\treturn false;\n\n\t\t// Wine and the mounted filesystem can round source/compiled timestamps differently.\n\t\treturn File.GetLastWriteTimeUtc( compiledAbsolute )\n\t\t\t>= File.GetLastWriteTimeUtc( sourceAbsolute ).AddSeconds( -2 );\n\t}\n\n\tprivate static async Task CompileAndInspectAsync(\n\t\tWeaponAnimationDocument document,\n\t\tstring outputRoot,\n\t\tstring relativeRoot,\n\t\tHostSkeleton skeleton,\n\t\tList<GenerationDiagnostic> diagnostics,\n\t\tAction<GenerationProgress>? progress,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\tvar bootstrapHostFile = $\"{slug}_vm_bootstrap.vmdl\";\n\t\tvar hostFile = $\"{slug}_vm.vmdl\";\n\t\tvar graphEnabled = document.Output.GenerateGraph && document.Graph.GenerateGraph;\n\t\tvar hostAbsolute = Path.Combine( outputRoot, hostFile );\n\t\tvar materialSources = WeaponMaterialPipeline.BuildOutputTextFiles(\n\t\t\tdocument,\n\t\t\trelativeRoot );\n\t\tvar materialCount = materialSources.Keys.Count( path =>\n\t\t\tPath.GetExtension( path ).Equals( \".vmat\", StringComparison.OrdinalIgnoreCase ) );\n\t\tvar compileTotal = materialCount\n\t\t\t+ (IsVmdlSource( document ) ? 1 : 0)\n\t\t\t+ (graphEnabled ? 3 : 1)\n\t\t\t+ (document.Output.GeneratePrefab ? 1 : 0);\n\t\tvar compileIndex = 0;\n\n\t\tasync Task<bool> Compile( string file, string? description = null )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tvar timer = Stopwatch.StartNew();\n\t\t\tcompileIndex++;\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\"Compile\",\n\t\t\t\tdescription ?? file,\n\t\t\t\tcompileIndex,\n\t\t\t\tcompileTotal ) );\n\t\t\tvar absolute = Path.Combine( outputRoot, file );\n\t\t\tvar registrationTimer = Stopwatch.StartNew();\n\t\t\tvar asset = AssetSystem.RegisterFile( absolute );\n\t\t\tLog.Info(\n\t\t\t\t$\"[Weapon Animator] consumer registration '{description ?? file}' took \"\n\t\t\t\t+ $\"{registrationTimer.ElapsedMilliseconds} ms.\" );\n\t\t\tif ( asset is null )\n\t\t\t{\n\t\t\t\tLog.Error( $\"[Weapon Animator] could not register '{absolute}' as an asset.\" );\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\t\"compile.failed\",\n\t\t\t\t\t$\"Could not register '{file}' with the asset system.\",\n\t\t\t\t\tabsolute ) );\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tif ( await WaitForCompileAsync(\n\t\t\t\tasset,\n\t\t\t\tabsolute,\n\t\t\t\tcancellationToken ) )\n\t\t\t{\n\t\t\t\tLog.Info(\n\t\t\t\t\t$\"[Weapon Animator] generation timing: compiled \"\n\t\t\t\t\t+ $\"'{description ?? file}' in {timer.ElapsedMilliseconds} ms.\" );\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Info,\n\t\t\t\t\t\"compile.ok\",\n\t\t\t\t\t$\"Compiled '{description ?? file}'.\",\n\t\t\t\t\tasset.Path ) );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tLog.Error( $\"[Weapon Animator] failed to compile '{absolute}'.\" );\n\t\t\tLog.Error(\n\t\t\t\t$\"[Weapon Animator] generation timing: failed '{description ?? file}' \"\n\t\t\t\t+ $\"after {timer.ElapsedMilliseconds} ms.\" );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\"compile.failed\",\n\t\t\t\t$\"Failed to compile '{description ?? file}'.\",\n\t\t\t\tabsolute ) );\n\t\t\treturn false;\n\t\t}\n\n\t\tforeach ( var materialFile in materialSources.Keys\n\t\t\t.Where( path => Path.GetExtension( path ).Equals(\n\t\t\t\t\".vmat\",\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t.OrderBy( path => path, StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tif ( !await Compile( materialFile, $\"{materialFile} material\" ) )\n\t\t\t\treturn;\n\t\t}\n\n\t\tif ( IsVmdlSource( document )\n\t\t\t&& !await Compile(\n\t\t\t\t$\"{slug}_source_adapter.vmdl\",\n\t\t\t\t$\"{slug}_source_adapter.vmdl source adapter\" ) )\n\t\t\treturn;\n\n\t\tvar hostCompiled = false;\n\t\tif ( graphEnabled )\n\t\t{\n\t\t\tvar graphPath = $\"{relativeRoot}/{slug}.vanmgrph\";\n\t\t\tvar linkedGraph = $\"anim_graph_name = \\\"{graphPath}\\\"\";\n\t\t\tvar finalHostSource = File.ReadAllText( hostAbsolute );\n\t\t\tif ( !finalHostSource.Contains( linkedGraph, StringComparison.Ordinal ) )\n\t\t\t{\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\t\"compile.graph_link\",\n\t\t\t\t\t\"The final host source does not contain its generated AnimGraph link.\",\n\t\t\t\t\thostAbsolute ) );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tvar bootstrapCompiled = await Compile(\n\t\t\t\tbootstrapHostFile,\n\t\t\t\t$\"{bootstrapHostFile} graph preview\" );\n\t\t\tvar graphCompiled = await Compile( $\"{slug}.vanmgrph\" );\n\t\t\tif ( bootstrapCompiled && graphCompiled )\n\t\t\t\thostCompiled = await Compile( hostFile, $\"{hostFile} with AnimGraph\" );\n\t\t}\n\t\telse\n\t\t{\n\t\t\thostCompiled = await Compile( hostFile );\n\t\t}\n\t\tif ( !hostCompiled )\n\t\t\treturn;\n\n\t\tvar hostPath = $\"{relativeRoot}/{slug}_vm.vmdl\";\n\t\tvar host = await ReloadGeneratedHostAsync(\n\t\t\thostAbsolute,\n\t\t\thostPath,\n\t\t\tskeleton,\n\t\t\tcancellationToken );\n\t\tvar missingRequiredBones = host is null || host.IsError\n\t\t\t? skeleton.Bones.Select( bone => bone.Name ).ToArray()\n\t\t\t: MissingRequiredBones( skeleton, host );\n\t\tif ( host is null || host.IsError || missingRequiredBones.Length > 0 )\n\t\t{\n\t\t\tLog.Error(\n\t\t\t\t$\"[Weapon Animator] host '{hostPath}' reloaded with {host?.BoneCount ?? 0} bones \"\n\t\t\t\t+ $\"(required {skeleton.Bones.Count}, missing {missingRequiredBones.Length}, \"\n\t\t\t\t+ $\"error: {host?.IsError.ToString() ?? \"not loaded\"}).\" );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\"inspect.host\",\n\t\t\t\t$\"Host reload is missing required bones: \"\n\t\t\t\t+ $\"{string.Join( \", \", missingRequiredBones.Take( 8 ) )}.\",\n\t\t\t\thostPath ) );\n\t\t\treturn;\n\t\t}\n\n\t\tvar additionalBones = AdditionalCompiledBones( skeleton, host );\n\t\tif ( additionalBones.Length > 0 )\n\t\t{\n\t\t\tLog.Info(\n\t\t\t\t$\"[Weapon Animator] compiled host includes {additionalBones.Length} additional \"\n\t\t\t\t+ $\"source-mesh bone entries: {string.Join( \", \", additionalBones.Take( 16 ) )}.\" );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Warning,\n\t\t\t\t\"inspect.additional_bones\",\n\t\t\t\t$\"The visible source mesh contributed {additionalBones.Length} additional compiled \"\n\t\t\t\t+ \"bone entries; all required animation-host bones are present.\",\n\t\t\t\thostPath ) );\n\t\t}\n\n\t\tvar normalizedScaleBones = CountCompiledScaleNormalizations( skeleton, host );\n\t\tif ( normalizedScaleBones > 0 )\n\t\t{\n\t\t\tLog.Info(\n\t\t\t\t$\"[Weapon Animator] compiler normalized bind scale on {normalizedScaleBones} \"\n\t\t\t\t+ \"bone(s); calibrated mesh scale remains baked into the generated model.\" );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Info,\n\t\t\t\t\"inspect.bind_scale_normalized\",\n\t\t\t\t$\"ModelDoc normalized bind scale on {normalizedScaleBones} bone(s).\",\n\t\t\t\thostPath ) );\n\t\t}\n\n\t\tvar bindIssues = InspectCompiledBindPose( skeleton, host );\n\t\tif ( bindIssues.Count > 0 )\n\t\t{\n\t\t\tforeach ( var issue in bindIssues.Take( 8 ) )\n\t\t\t{\n\t\t\t\tvar expectedBone = skeleton.ByName[issue.BoneName];\n\t\t\t\tvar actualBone = host.Bones.GetBone( issue.BoneName );\n\t\t\t\tLog.Error(\n\t\t\t\t\t$\"[Weapon Animator] compiled bind mismatch '{issue.BoneName}': \"\n\t\t\t\t\t+ $\"expectedParent='{expectedBone.ParentName}', \"\n\t\t\t\t\t+ $\"actualParent='{actualBone?.Parent?.Name ?? \"\"}', \"\n\t\t\t\t\t+ $\"position={issue.PositionDelta:0.######}, \"\n\t\t\t\t\t+ $\"rotation={issue.RotationDelta:0.######}, \"\n\t\t\t\t\t+ $\"scale={issue.ScaleDelta:0.######}, \"\n\t\t\t\t\t+ $\"expected={DescribeTransform( issue.Expected )}, \"\n\t\t\t\t\t+ $\"actual={DescribeTransform( issue.Actual )}.\" );\n\t\t\t}\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\"inspect.bind_pose\",\n\t\t\t\t$\"Compiled host bind pose differs from the authored host on {bindIssues.Count} \"\n\t\t\t\t+ $\"bone(s); first mismatch: {bindIssues[0].BoneName}.\",\n\t\t\t\thostPath ) );\n\t\t\treturn;\n\t\t}\n\n\t\tLogRotatingWeaponPivotDiagnostics( document, skeleton, host, diagnostics, hostPath );\n\n\t\tvar sequenceNames = Enumerable.Range( 0, host.AnimationCount )\n\t\t\t.Select( host.GetAnimationName )\n\t\t\t.Where( name => !string.IsNullOrWhiteSpace( name ) )\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tvar expectedSequences = GeneratedClips( document )\n\t\t\t.Select( WeaponAnimationNames.SequenceName )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\t\tvar missingSequences = expectedSequences\n\t\t\t.Where( name => !sequenceNames.Contains( name ) )\n\t\t\t.ToArray();\n\t\tLog.Info(\n\t\t\t$\"[Weapon Animator] host '{hostPath}' exposes {host.AnimationCount} animation \"\n\t\t\t+ $\"sequence(s): {string.Join( \", \", sequenceNames.OrderBy( x => x ) )}.\" );\n\t\tif ( missingSequences.Length > 0 )\n\t\t{\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\"inspect.sequences\",\n\t\t\t\t$\"Compiled host is missing generated animation sequences: \"\n\t\t\t\t+ $\"{string.Join( \", \", missingSequences )}.\",\n\t\t\t\thostPath ) );\n\t\t\treturn;\n\t\t}\n\t\tdiagnostics.Add( Diagnostic(\n\t\t\tValidationSeverity.Info,\n\t\t\t\"inspect.sequences\",\n\t\t\t$\"Host exposes all {expectedSequences.Length} generated animation sequences.\",\n\t\t\thostPath ) );\n\n\t\tif ( graphEnabled )\n\t\t{\n\t\t\tvar graph = host.AnimGraph;\n\t\t\tvar requiredParameters = new[] { \"b_attack\", \"b_reload\", \"b_empty\" };\n\t\t\tvar missing = graph is null || graph.IsError\n\t\t\t\t? requiredParameters\n\t\t\t\t: requiredParameters\n\t\t\t\t\t.Where( name => !graph.TryGetParameterIndex( name, out _ ) )\n\t\t\t\t\t.ToArray();\n\t\t\tif ( graph is null || graph.IsError || missing.Length > 0 )\n\t\t\t{\n\t\t\t\tLog.Error(\n\t\t\t\t\t$\"[Weapon Animator] host '{hostPath}' has no usable AnimGraph parameters. \"\n\t\t\t\t\t+ $\"Graph loaded: {graph is not null}, graph error: {graph?.IsError.ToString() ?? \"n/a\"}, \"\n\t\t\t\t\t+ $\"missing: {string.Join( \", \", missing )}.\" );\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\t\"inspect.animgraph\",\n\t\t\t\t\t$\"Host reload is missing AnimGraph parameters: {string.Join( \", \", missing )}.\",\n\t\t\t\t\thostPath ) );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Info,\n\t\t\t\t\t\"inspect.animgraph\",\n\t\t\t\t\t$\"Host exposes {graph.ParamCount} AnimGraph parameters, including the Facepunch firearm profile.\",\n\t\t\t\t\thostPath ) );\n\t\t\t}\n\t\t}\n\n\t\t// Compile the prefab only after its model and graph have reloaded successfully. This keeps\n\t\t// a transient model-cache delay from producing and then rolling back a dependent prefab.\n\t\tif ( document.Output.GeneratePrefab )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tawait Compile( $\"v_{slug}.prefab\" );\n\t\t}\n\t}\n\n\tprivate static async Task<Model?> ReloadGeneratedHostAsync(\n\t\tstring hostAbsolute,\n\t\tstring hostPath,\n\t\tHostSkeleton skeleton,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar expectedBoneCount = skeleton.Bones.Count;\n\t\tvar started = DateTime.UtcNow;\n\t\tvar deadline = started.AddSeconds( HostReloadTimeoutSeconds );\n\t\tvar nextProgressLog = started.AddSeconds( 2 );\n\t\tModel? last = null;\n\n\t\twhile ( DateTime.UtcNow <= deadline )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tvar asset = AssetSystem.FindByPath( hostAbsolute );\n\t\t\t// Successful compilation can precede resource hotload by several frames. Reacquire\n\t\t\t// both the Asset and Model until the replacement resource is visible.\n\t\t\tawait Task.Delay( 50, cancellationToken );\n\t\t\ttry\n\t\t\t{\n\t\t\t\tlast = asset?.LoadResource<Model>() ?? Model.Load( hostPath );\n\t\t\t}\n\t\t\tcatch ( Exception ex )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\"[Weapon Animator] host reload attempt for '{hostPath}' threw: {ex.Message}\" );\n\t\t\t\tlast = null;\n\t\t\t}\n\n\t\t\tvar missing = last is null || last.IsError\n\t\t\t\t? expectedBoneCount\n\t\t\t\t: MissingRequiredBones( skeleton, last ).Length;\n\t\t\tif ( last is not null && !last.IsError && missing == 0 )\n\t\t\t{\n\t\t\t\tvar elapsed = (DateTime.UtcNow - started).TotalMilliseconds;\n\t\t\t\tLog.Info(\n\t\t\t\t\t$\"[Weapon Animator] host '{hostPath}' reloaded with \"\n\t\t\t\t\t+ $\"{last.BoneCount} bones after {elapsed:0} ms.\" );\n\t\t\t\treturn last;\n\t\t\t}\n\n\t\t\tif ( DateTime.UtcNow >= nextProgressLog )\n\t\t\t{\n\t\t\t\tLog.Info(\n\t\t\t\t\t$\"[Weapon Animator] waiting for host resource '{hostPath}': \"\n\t\t\t\t\t+ $\"bones={last?.BoneCount ?? 0} (required {expectedBoneCount}, missing {missing}), \"\n\t\t\t\t\t+ $\"error={last?.IsError.ToString() ?? \"not loaded\"}, \"\n\t\t\t\t\t+ $\"assetPresent={asset is not null}, \"\n\t\t\t\t\t+ $\"compiledArtifact={File.Exists( hostAbsolute + \"_c\" )}.\" );\n\t\t\t\tnextProgressLog = DateTime.UtcNow.AddSeconds( 2 );\n\t\t\t}\n\t\t}\n\n\t\treturn last;\n\t}\n\n\tprivate static string[] MissingRequiredBones(\n\t\tHostSkeleton skeleton,\n\t\tModel host ) =>\n\t\tskeleton.Bones\n\t\t\t.Where( expected => host.Bones.GetBone( expected.Name ) is null )\n\t\t\t.Select( expected => expected.Name )\n\t\t\t.ToArray();\n\n\tprivate static string[] AdditionalCompiledBones(\n\t\tHostSkeleton skeleton,\n\t\tModel host )\n\t{\n\t\tvar expected = skeleton.Bones\n\t\t\t.Select( bone => bone.Name )\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tvar additionalNames = host.Bones.AllBones\n\t\t\t.Where( bone => !expected.Contains( bone.Name ) )\n\t\t\t.Select( bone => bone.Name );\n\t\tvar duplicateNames = host.Bones.AllBones\n\t\t\t.GroupBy( bone => bone.Name, StringComparer.OrdinalIgnoreCase )\n\t\t\t.Where( group => group.Count() > 1 )\n\t\t\t.Select( group => $\"{group.Key} \u00d7{group.Count()}\" );\n\t\treturn additionalNames\n\t\t\t.Concat( duplicateNames )\n\t\t\t.OrderBy( name => name, StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\t}\n\n\tinternal sealed record CompiledBindIssue(\n\t\tstring BoneName,\n\t\tfloat PositionDelta,\n\t\tfloat RotationDelta,\n\t\tfloat ScaleDelta,\n\t\tTransform Expected,\n\t\tTransform Actual );\n\n\tprivate static IReadOnlyList<CompiledBindIssue> InspectCompiledBindPose(\n\t\tHostSkeleton skeleton,\n\t\tModel host,\n\t\tfloat positionTolerance = 0.02f,\n\t\tfloat rotationTolerance = 0.005f )\n\t{\n\t\tvar issues = new List<CompiledBindIssue>();\n\t\tvar compiledExpectation = skeleton.BuildCompilerBindModelTransforms();\n\t\tforeach ( var expected in skeleton.Bones )\n\t\t{\n\t\t\tvar actualBone = host.Bones.GetBone( expected.Name );\n\t\t\tvar expectedTransform = compiledExpectation[expected.Name];\n\t\t\tif ( actualBone is null )\n\t\t\t{\n\t\t\t\tissues.Add( new CompiledBindIssue(\n\t\t\t\t\texpected.Name,\n\t\t\t\t\tfloat.PositiveInfinity,\n\t\t\t\t\tfloat.PositiveInfinity,\n\t\t\t\t\tfloat.PositiveInfinity,\n\t\t\t\t\texpectedTransform,\n\t\t\t\t\tTransform.Zero ) );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar actual = actualBone.LocalTransform;\n\t\t\tvar positionDelta = expectedTransform.Position.Distance( actual.Position );\n\t\t\tvar rotationDelta = MathF.Max(\n\t\t\t\t(expectedTransform.Rotation.Forward - actual.Rotation.Forward).Length,\n\t\t\t\t(expectedTransform.Rotation.Up - actual.Rotation.Up).Length );\n\t\t\tvar scaleDelta = (expectedTransform.Scale - actual.Scale).Length;\n\t\t\tif ( positionDelta > positionTolerance\n\t\t\t\t|| rotationDelta > rotationTolerance )\n\t\t\t{\n\t\t\t\tissues.Add( new CompiledBindIssue(\n\t\t\t\t\texpected.Name,\n\t\t\t\t\tpositionDelta,\n\t\t\t\t\trotationDelta,\n\t\t\t\t\tscaleDelta,\n\t\t\t\t\texpectedTransform,\n\t\t\t\t\tactual ) );\n\t\t\t}\n\t\t}\n\n\t\treturn issues;\n\t}\n\n\tprivate static int CountCompiledScaleNormalizations(\n\t\tHostSkeleton skeleton,\n\t\tModel host,\n\t\tfloat scaleTolerance = 0.005f ) =>\n\t\tskeleton.Bones.Count( expected =>\n\t\t{\n\t\t\tvar actual = host.Bones.GetBone( expected.Name );\n\t\t\treturn actual is not null\n\t\t\t\t&& (expected.BindModelTransform.Scale - actual.LocalTransform.Scale).Length\n\t\t\t\t\t> scaleTolerance;\n\t\t} );\n\n\tprivate static void LogRotatingWeaponPivotDiagnostics(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tModel host,\n\t\tList<GenerationDiagnostic> diagnostics,\n\t\tstring hostPath )\n\t{\n\t\tvar compilerLocal = skeleton.BuildCompilerBindLocalTransforms();\n\t\tvar targets = document.Clips\n\t\t\t.SelectMany( clip => clip.Tracks )\n\t\t\t.Where( track => skeleton.ByName.TryGetValue( track.Target, out var bone )\n\t\t\t\t&& bone.IsWeaponBone\n\t\t\t\t&& track.Keys.Any( key => RotationDiffers(\n\t\t\t\t\tkey.Rotation,\n\t\t\t\t\tskeleton.GetBindLocal( bone ).Rotation ) ) )\n\t\t\t.Select( track => track.Target )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( name => name, StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\n\t\tforeach ( var target in targets )\n\t\t{\n\t\t\tvar expected = skeleton.ByName[target];\n\t\t\tvar actual = host.Bones.GetBone( target );\n\t\t\tvar actualParent = actual?.Parent;\n\t\t\tvar actualLocal = actual is null\n\t\t\t\t? Transform.Zero\n\t\t\t\t: actualParent is null\n\t\t\t\t\t? actual.LocalTransform\n\t\t\t\t\t: actualParent.LocalTransform.ToLocal( actual.LocalTransform );\n\t\t\tLog.Info(\n\t\t\t\t$\"[Weapon Animator] rotating weapon pivot '{target}': \"\n\t\t\t\t+ $\"parent expected='{expected.ParentName}', actual='{actualParent?.Name ?? \"\"}', \"\n\t\t\t\t+ $\"authoredLocal={DescribeTransform( skeleton.GetBindLocal( expected ) )}, \"\n\t\t\t\t+ $\"exportLocal={DescribeTransform( compilerLocal[target] )}, \"\n\t\t\t\t+ $\"compiledLocal={DescribeTransform( actualLocal )}, \"\n\t\t\t\t+ $\"compiledModel={DescribeTransform( actual?.LocalTransform ?? Transform.Zero )}.\" );\n\t\t}\n\n\t\tif ( targets.Length > 0 )\n\t\t{\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Info,\n\t\t\t\t\"inspect.rotation_pivots\",\n\t\t\t\t$\"Verified {targets.Length} rotation-driven weapon bone pivot(s) in compiled bind space.\",\n\t\t\t\thostPath ) );\n\t\t}\n\t}\n\n\tprivate static bool RotationDiffers(\n\t\tRotation left,\n\t\tRotation right,\n\t\tfloat tolerance = 0.001f ) =>\n\t\t(left.Forward - right.Forward).Length > tolerance\n\t\t\t|| (left.Up - right.Up).Length > tolerance;\n\n\tprivate static string DescribeTransform( Transform transform ) =>\n\t\t$\"pos({transform.Position.x:0.####},{transform.Position.y:0.####},{transform.Position.z:0.####}) \"\n\t\t+ $\"rot({transform.Rotation.x:0.####},{transform.Rotation.y:0.####},\"\n\t\t+ $\"{transform.Rotation.z:0.####},{transform.Rotation.w:0.####}) \"\n\t\t+ $\"scale({transform.Scale.x:0.####},{transform.Scale.y:0.####},\"\n\t\t+ $\"{transform.Scale.z:0.####})\";\n\n\tprivate static string ResolveOutputRoot( WeaponAnimationDocument document )\n\t{\n\t\treturn ResolveOutputRootForContentRoot(\n\t\t\tdocument,\n\t\t\tWeaponSourceImporter.GetContentRoot() );\n\t}\n\n\tinternal static string ResolveOutputRootForContentRoot(\n\t\tWeaponAnimationDocument document,\n\t\tstring contentRoot )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( contentRoot ) )\n\t\t\tthrow new InvalidOperationException( \"The current project's Assets directory is unavailable.\" );\n\n\t\tdocument.Output ??= new OutputSettings\n\t\t{\n\t\t\tAssetName = WeaponAnimationDocument.Slugify( document.Name )\n\t\t};\n\t\tvar configured = string.IsNullOrWhiteSpace( document.Output.OutputFolder )\n\t\t\t? document.Output.GetDefaultRelativeFolder()\n\t\t\t: document.Output.OutputFolder;\n\t\tconfigured = configured.Trim().Replace( '\\\\', '/' ).TrimStart( '/' );\n\t\tif ( string.IsNullOrWhiteSpace( configured ) )\n\t\t\tconfigured = document.Output.GetDefaultRelativeFolder();\n\t\tif ( configured.Length >= 2\n\t\t\t&& char.IsLetter( configured[0] )\n\t\t\t&& configured[1] == ':' )\n\t\t{\n\t\t\tthrow new InvalidOperationException(\n\t\t\t\t\"Generated output must use a path relative to the project's Assets folder.\" );\n\t\t}\n\n\t\tvar assetsRoot = Path.GetFullPath( contentRoot );\n\t\tvar full = Path.GetFullPath( Path.Combine(\n\t\t\tassetsRoot,\n\t\t\tconfigured.Replace( '/', Path.DirectorySeparatorChar ) ) );\n\t\tvar relative = Path.GetRelativePath( assetsRoot, full );\n\t\tif ( Path.IsPathRooted( relative )\n\t\t\t|| relative.Equals( \"..\", StringComparison.Ordinal )\n\t\t\t|| relative.StartsWith( $\"..{Path.DirectorySeparatorChar}\", StringComparison.Ordinal )\n\t\t\t|| relative.StartsWith( $\"..{Path.AltDirectorySeparatorChar}\", StringComparison.Ordinal ) )\n\t\t\tthrow new InvalidOperationException( \"Generated output must stay inside the project's Assets folder.\" );\n\t\treturn full;\n\t}\n\n\tprivate static void LoadOwnershipManifest(\n\t\tWeaponAnimationDocument document,\n\t\tstring outputRoot )\n\t{\n\t\tvar manifestPath = Path.Combine( outputRoot, ManifestFile );\n\t\tif ( !File.Exists( manifestPath ) )\n\t\t{\n\t\t\tdocument.Manifest ??= new GenerationManifest();\n\t\t\treturn;\n\t\t}\n\n\t\tvar manifest = Json.Deserialize<GenerationManifest>(\n\t\t\tFile.ReadAllText( manifestPath ) );\n\t\tif ( manifest is null )\n\t\t\tthrow new InvalidDataException( $\"'{manifestPath}' does not contain a valid manifest.\" );\n\n\t\tdocument.Manifest = manifest;\n\t}\n\n\tprivate static string InputHash( WeaponAnimationDocument document )\n\t{\n\t\tvar clone = Json.Deserialize<WeaponAnimationDocument>( Json.Serialize( document ) )\n\t\t\t?? throw new InvalidOperationException( \"Could not clone the weapon animation document.\" );\n\t\tclone.Manifest = new GenerationManifest();\n\t\tclone.Workspace = new WorkspaceState();\n\t\treturn HashText( Json.Serialize( clone ) );\n\t}\n\n\tprivate static GenerationManifest BuildAndWriteManifest(\n\t\tWeaponAnimationDocument document,\n\t\tstring outputRoot,\n\t\tIEnumerable<string> generatedSourcePaths,\n\t\tIReadOnlyDictionary<string, string> textFiles,\n\t\tList<GenerationDiagnostic> diagnostics,\n\t\tDictionary<string, byte[]> backups,\n\t\tHashSet<string> newFiles,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\tvar inputHash = InputHash( document );\n\t\tvar generatedUtc = document.Manifest.InputHash == inputHash\n\t\t\t? document.Manifest.GeneratedUtc\n\t\t\t: DateTime.UtcNow;\n\t\tif ( generatedUtc == default )\n\t\t\tgeneratedUtc = DateTime.UtcNow;\n\n\t\tvar records = new List<GeneratedFileRecord>();\n\t\tforeach ( var path in generatedSourcePaths.OrderBy( path => path ) )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\trecords.Add( new GeneratedFileRecord\n\t\t\t{\n\t\t\t\tRelativePath = path.Replace( '\\\\', '/' ),\n\t\t\t\tSha256 = textFiles.TryGetValue( path, out var text )\n\t\t\t\t\t? HashText( text )\n\t\t\t\t\t: WeaponSourceImporter.HashFile(\n\t\t\t\t\t\tPath.Combine( outputRoot, path ) ),\n\t\t\t\tKind = Path.GetExtension( path ).TrimStart( '.' )\n\t\t\t} );\n\t\t}\n\n\t\tvar manifest = new GenerationManifest\n\t\t{\n\t\t\tGeneratorVersion = GeneratorVersion,\n\t\t\tGeneratedUtc = generatedUtc,\n\t\t\tInputHash = inputHash,\n\t\t\tDiagnostics = diagnostics,\n\t\t\tFiles = records\n\t\t};\n\t\tvar manifestPath = Path.Combine( outputRoot, ManifestFile );\n\t\tif ( File.Exists( manifestPath ) )\n\t\t\tbackups.TryAdd( manifestPath, File.ReadAllBytes( manifestPath ) );\n\t\telse\n\t\t\tnewFiles.Add( manifestPath );\n\t\tAtomicFile.WriteAllText( manifestPath, Json.Serialize( manifest ) );\n\t\treturn manifest;\n\t}\n\n\tprivate static WeaponAnimationDocument CreateGenerationSnapshot(\n\t\tWeaponAnimationDocument document )\n\t{\n\t\tvar snapshot = Json.Deserialize<WeaponAnimationDocument>(\n\t\t\tJson.Serialize( document ) )\n\t\t\t?? throw new InvalidOperationException(\n\t\t\t\t\"Could not clone the weapon animation document.\" );\n\t\tsnapshot.Manifest = Json.Deserialize<GenerationManifest>(\n\t\t\tJson.Serialize( document.Manifest ?? new GenerationManifest() ) )\n\t\t\t?? new GenerationManifest();\n\t\treturn snapshot;\n\t}\n\n\tprivate static string HashText( string value ) =>\n\t\tConvert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( value ) ) )\n\t\t\t.ToLowerInvariant();\n\n\tprivate static void RestoreGeneratedFiles(\n\t\tIEnumerable<string> newFiles,\n\t\tIReadOnlyDictionary<string, byte[]> backups )\n\t{\n\t\tDeleteGeneratedFiles( newFiles );\n\t\tforeach ( var backup in backups )\n\t\t{\n\t\t\tvar directory = Path.GetDirectoryName( backup.Key );\n\t\t\tif ( !string.IsNullOrWhiteSpace( directory ) )\n\t\t\t\tDirectory.CreateDirectory( directory );\n\t\t\tFile.WriteAllBytes( backup.Key, backup.Value );\n\t\t}\n\n\t\t// Consumer compiled files were deliberately removed before rewriting. Re-register their\n\t\t// restored sources so cancellation leaves the previous viewmodel usable after recompilation.\n\t\tforeach ( var source in OrderForWrite(\n\t\t\tbackups.Keys.Where( IsCompiledConsumer ) ) )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tvar asset = AssetSystem.RegisterFile( source )\n\t\t\t\t\t?? AssetSystem.FindByPath( source );\n\t\t\t\tasset?.Compile( true );\n\t\t\t}\n\t\t\tcatch ( Exception ex )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\"[Weapon Animator] could not requeue restored asset '{source}': {ex.Message}\" );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static GenerationResult CancelledResult(\n\t\tValidationReport validation,\n\t\tstring outputFolder = \"\" ) => new()\n\t{\n\t\tSuccess = false,\n\t\tCancelled = true,\n\t\tOutputFolder = outputFolder,\n\t\tValidation = validation,\n\t\tDiagnostics =\n\t\t[\n\t\t\tDiagnostic(\n\t\t\t\tValidationSeverity.Warning,\n\t\t\t\t\"generation.cancelled\",\n\t\t\t\t\"Generation was cancelled before any output files were changed.\" )\n\t\t]\n\t};\n\n\tprivate static GenerationResult Failed(\n\t\tValidationReport validation,\n\t\tstring code,\n\t\tstring message ) => new()\n\t{\n\t\tSuccess = false,\n\t\tValidation = validation,\n\t\tDiagnostics = [Diagnostic( ValidationSeverity.Error, code, message )]\n\t};\n\n\tprivate static GenerationDiagnostic Diagnostic(\n\t\tValidationSeverity severity,\n\t\tstring code,\n\t\tstring message,\n\t\tstring assetPath = \"\" ) => new()\n\t{\n\t\tSeverity = severity,\n\t\tCode = code,\n\t\tMessage = message,\n\t\tAssetPath = assetPath\n\t};\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Services/DmxWriter.cs",
            "FileName": "DmxWriter.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing System.Security.Cryptography;\nusing System.Text;\nusing System.Threading;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic static class DmxWriter\n{\n\tprivate static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;\n\tprivate const string CarrierMaterial = \"materials/tools/toolsinvisible.vmat\";\n\tprivate static readonly Vector3 BoneVisibilitySinkOffset = new( 0, 0, -8192 );\n\n\tpublic static string WriteAnimation(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tWeaponAnimationClip clip,\n\t\tCancellationToken cancellationToken = default )\n\t{\n\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\tif ( skeleton.Bones.Count == 0 )\n\t\t\tthrow new InvalidOperationException( \"The animation host skeleton contains no bones.\" );\n\n\t\tvar sampleRate = MathF.Max( clip.SampleRate, 1.0f );\n\t\tvar frameCount = Math.Max( 1, (int)MathF.Round( clip.Duration * sampleRate ) );\n\t\tvar compilerBindLocal = skeleton.BuildCompilerBindLocalTransforms();\n\t\tvar times = new string[frameCount + 1];\n\t\tvar poses = new IReadOnlyDictionary<string, Transform>[frameCount + 1];\n\t\tfor ( var frame = 0; frame <= frameCount; frame++ )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tif ( (frame & 7) == 7 )\n\t\t\t\tThread.Yield();\n\t\t\tvar time = MathF.Min( frame / sampleRate, clip.Duration );\n\t\t\ttimes[frame] = F( time );\n\t\t\tvar evaluated = AnimationPoseEvaluator.Evaluate( document, skeleton, clip, time );\n\t\t\tApplyBoneVisibility( document, skeleton, clip, time, evaluated );\n\t\t\tposes[frame] = BuildCompilerPoseLocals( skeleton, evaluated.Local );\n\t\t}\n\n\t\tvar prefix = $\"animation:{clip.Id}\";\n\t\tvar rootId = Id( $\"{prefix}:root\" );\n\t\tvar modelId = Id( $\"{prefix}:model\" );\n\t\tvar modelTransformId = Id( $\"{prefix}:model-transform\" );\n\t\tvar baseStateId = Id( $\"{prefix}:base-state\" );\n\t\tvar baseModelTransformId = Id( $\"{prefix}:base-model-transform\" );\n\t\tvar animationListId = Id( $\"{prefix}:animation-list\" );\n\t\tvar clipId = Id( $\"{prefix}:clip\" );\n\t\tvar timeFrameId = Id( $\"{prefix}:time-frame\" );\n\t\tvar builder = new StringBuilder();\n\n\t\tbuilder.AppendLine( \"<!-- dmx encoding keyvalues2 4 format model 22 -->\" );\n\t\tbuilder.AppendLine( \"\\\"DmElement\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", rootId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"root\" );\n\t\tAttribute( builder, 1, \"skeleton\", \"element\", modelId );\n\t\tAttribute( builder, 1, \"animationList\", \"element\", animationListId );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeModel\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", modelId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"weapon_animation_host\" );\n\t\tAttribute( builder, 1, \"transform\", \"element\", modelTransformId );\n\t\tAttribute( builder, 1, \"shape\", \"element\", \"\" );\n\t\tAttribute( builder, 1, \"visible\", \"bool\", \"1\" );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"children\",\n\t\t\tskeleton.Bones\n\t\t\t\t.Where( bone => string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t|| !skeleton.ByName.ContainsKey( bone.ParentName ) )\n\t\t\t\t.Select( bone => AnimationJointId( prefix, bone.Index ) ) );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"jointList\",\n\t\t\tnew[] { modelId }.Concat(\n\t\t\t\tskeleton.Bones.Select( bone => AnimationJointId( prefix, bone.Index ) ) ) );\n\t\tElementArray( builder, 1, \"baseStates\", [baseStateId] );\n\t\tAttribute( builder, 1, \"upAxis\", \"string\", \"Z\" );\n\t\tbuilder.AppendLine( \"\\t\\\"axisSystem\\\" \\\"DmeAxisSystem\\\"\" );\n\t\tbuilder.AppendLine( \"\\t{\" );\n\t\tAttribute( builder, 2, \"id\", \"elementid\", Id( $\"{prefix}:axis-system\" ) );\n\t\tAttribute( builder, 2, \"name\", \"string\", \"\" );\n\t\tAttribute( builder, 2, \"upAxis\", \"int\", \"3\" );\n\t\tAttribute( builder, 2, \"forwardParity\", \"int\", \"1\" );\n\t\tAttribute( builder, 2, \"coordSys\", \"int\", \"0\" );\n\t\tbuilder.AppendLine( \"\\t}\" );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tforeach ( var bone in skeleton.Bones )\n\t\t\tWriteAnimationJoint( builder, skeleton, bone, prefix );\n\n\t\tExternalTransformElement( builder, modelTransformId, \"model\", Transform.Zero );\n\t\tbuilder.AppendLine();\n\n\t\tforeach ( var bone in skeleton.Bones )\n\t\t{\n\t\t\tExternalTransformElement(\n\t\t\t\tbuilder,\n\t\t\t\tAnimationTransformId( prefix, bone.Index ),\n\t\t\t\tbone.Name,\n\t\t\t\tcompilerBindLocal[bone.Name] );\n\t\t\tbuilder.AppendLine();\n\t\t}\n\n\t\tExternalTransformElement( builder, baseModelTransformId, \"model\", Transform.Zero );\n\t\tbuilder.AppendLine();\n\t\tforeach ( var bone in skeleton.Bones )\n\t\t{\n\t\t\tExternalTransformElement(\n\t\t\t\tbuilder,\n\t\t\t\tAnimationBaseTransformId( prefix, bone.Index ),\n\t\t\t\tbone.Name,\n\t\t\t\tcompilerBindLocal[bone.Name] );\n\t\t\tbuilder.AppendLine();\n\t\t}\n\n\t\tbuilder.AppendLine( \"\\\"DmeTransformList\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", baseStateId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"base\" );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"transforms\",\n\t\t\tnew[] { baseModelTransformId }.Concat(\n\t\t\t\tskeleton.Bones.Select( bone =>\n\t\t\t\t\tAnimationBaseTransformId( prefix, bone.Index ) ) ) );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeAnimationList\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", animationListId );\n\t\tAttribute( builder, 1, \"name\", \"string\", clip.Name );\n\t\tElementArray( builder, 1, \"animations\", [clipId] );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeChannelsClip\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", clipId );\n\t\tAttribute( builder, 1, \"name\", \"string\", WeaponAnimationNames.SequenceName( clip ) );\n\t\tAttribute( builder, 1, \"timeFrame\", \"element\", timeFrameId );\n\t\tAttribute( builder, 1, \"color\", \"color\", \"0 0 0 0\" );\n\t\tAttribute( builder, 1, \"text\", \"string\", \"\" );\n\t\tAttribute( builder, 1, \"mute\", \"bool\", \"0\" );\n\t\tElementArray( builder, 1, \"trackGroups\", [] );\n\t\tAttribute( builder, 1, \"displayScale\", \"float\", \"1\" );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"channels\",\n\t\t\tskeleton.Bones.SelectMany( bone => new[]\n\t\t\t{\n\t\t\t\tAnimationChannelId( prefix, bone.Index, \"position\" ),\n\t\t\t\tAnimationChannelId( prefix, bone.Index, \"orientation\" ),\n\t\t\t\tAnimationChannelId( prefix, bone.Index, \"scale\" )\n\t\t\t} ) );\n\t\tAttribute( builder, 1, \"frameRate\", \"float\", F( sampleRate ) );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeTimeFrame\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", timeFrameId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"timeFrame\" );\n\t\tAttribute( builder, 1, \"start\", \"time\", \"0\" );\n\t\tAttribute( builder, 1, \"duration\", \"time\", F( clip.Duration ) );\n\t\tAttribute( builder, 1, \"offset\", \"time\", \"0\" );\n\t\tAttribute( builder, 1, \"scale\", \"float\", \"1\" );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tforeach ( var bone in skeleton.Bones )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tvar values = poses\n\t\t\t\t.Select( pose => pose[bone.Name] )\n\t\t\t\t.ToArray();\n\t\t\tWriteVectorChannel(\n\t\t\t\tbuilder,\n\t\t\t\tprefix,\n\t\t\t\tbone,\n\t\t\t\t\"position\",\n\t\t\t\ttimes,\n\t\t\t\tvalues.Select( value => Vector( value.Position ) ).ToArray() );\n\t\t\tWriteQuaternionChannel(\n\t\t\t\tbuilder,\n\t\t\t\tprefix,\n\t\t\t\tbone,\n\t\t\t\ttimes,\n\t\t\t\tvalues.Select( value => Quaternion( value.Rotation.Normal ) ).ToArray() );\n\t\t\tWriteFloatChannel(\n\t\t\t\tbuilder,\n\t\t\t\tprefix,\n\t\t\t\tbone,\n\t\t\t\ttimes,\n\t\t\t\tvalues.Select( value => F( value.Scale.x ) ).ToArray() );\n\t\t}\n\n\t\treturn builder.ToString();\n\t}\n\n\tinternal static IReadOnlyDictionary<string, Transform> BuildCompilerPoseLocals(\n\t\tHostSkeleton skeleton,\n\t\tIReadOnlyDictionary<string, Transform> authoredLocal )\n\t{\n\t\tvar authoredModel = BuildModelTransforms( skeleton, authoredLocal );\n\t\tvar exportLocal = new Dictionary<string, Transform>( StringComparer.OrdinalIgnoreCase );\n\t\tvar exportModel = new Dictionary<string, Transform>( StringComparer.OrdinalIgnoreCase );\n\t\tvar pending = skeleton.Bones.ToList();\n\t\twhile ( pending.Count > 0 )\n\t\t{\n\t\t\tvar progressed = false;\n\t\t\tfor ( var i = pending.Count - 1; i >= 0; i-- )\n\t\t\t{\n\t\t\t\tvar bone = pending[i];\n\t\t\t\tif ( !authoredModel.TryGetValue( bone.Name, out var desiredModel ) )\n\t\t\t\t{\n\t\t\t\t\tpending.RemoveAt( i );\n\t\t\t\t\tprogressed = true;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif ( !string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t&& skeleton.ByName.ContainsKey( bone.ParentName )\n\t\t\t\t\t&& !exportModel.ContainsKey( bone.ParentName ) )\n\t\t\t\t{\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tvar authored = authoredLocal[bone.Name];\n\t\t\t\tvar bind = skeleton.GetBindLocal( bone );\n\t\t\t\tvar relativeScale = new Vector3(\n\t\t\t\t\tScaleRatio( authored.Scale.x, bind.Scale.x ),\n\t\t\t\t\tScaleRatio( authored.Scale.y, bind.Scale.y ),\n\t\t\t\t\tScaleRatio( authored.Scale.z, bind.Scale.z ) );\n\t\t\t\tTransform local;\n\t\t\t\tif ( string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t|| !exportModel.TryGetValue( bone.ParentName, out var parent ) )\n\t\t\t\t{\n\t\t\t\t\tlocal = new Transform(\n\t\t\t\t\t\tdesiredModel.Position,\n\t\t\t\t\t\tdesiredModel.Rotation.Normal,\n\t\t\t\t\t\trelativeScale );\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tlocal = new Transform(\n\t\t\t\t\t\tparent.PointToLocal( desiredModel.Position ),\n\t\t\t\t\t\t(parent.Rotation.Inverse * desiredModel.Rotation).Normal,\n\t\t\t\t\t\trelativeScale );\n\t\t\t\t}\n\n\t\t\t\texportLocal[bone.Name] = local;\n\t\t\t\texportModel[bone.Name] = string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t|| !exportModel.TryGetValue( bone.ParentName, out var exportParent )\n\t\t\t\t\t\t? local\n\t\t\t\t\t\t: ComposeLocal( exportParent, local );\n\t\t\t\tpending.RemoveAt( i );\n\t\t\t\tprogressed = true;\n\t\t\t}\n\n\t\t\tif ( progressed )\n\t\t\t\tcontinue;\n\n\t\t\tthrow new InvalidOperationException(\n\t\t\t\t$\"The animation host contains a cyclic pose hierarchy near '{pending[0].Name}'.\" );\n\t\t}\n\n\t\treturn exportLocal;\n\t}\n\n\tprivate static IReadOnlyDictionary<string, Transform> BuildModelTransforms(\n\t\tHostSkeleton skeleton,\n\t\tIReadOnlyDictionary<string, Transform> local )\n\t{\n\t\tvar model = new Dictionary<string, Transform>( StringComparer.OrdinalIgnoreCase );\n\t\tvar pending = skeleton.Bones.ToList();\n\t\twhile ( pending.Count > 0 )\n\t\t{\n\t\t\tvar progressed = false;\n\t\t\tfor ( var i = pending.Count - 1; i >= 0; i-- )\n\t\t\t{\n\t\t\t\tvar bone = pending[i];\n\t\t\t\tif ( !local.TryGetValue( bone.Name, out var boneLocal ) )\n\t\t\t\t{\n\t\t\t\t\tpending.RemoveAt( i );\n\t\t\t\t\tprogressed = true;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\tif ( !string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t&& skeleton.ByName.ContainsKey( bone.ParentName )\n\t\t\t\t\t&& !model.ContainsKey( bone.ParentName ) )\n\t\t\t\t{\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tmodel[bone.Name] = string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t|| !model.TryGetValue( bone.ParentName, out var parent )\n\t\t\t\t\t\t? boneLocal\n\t\t\t\t\t\t: ComposeLocal( parent, boneLocal );\n\t\t\t\tpending.RemoveAt( i );\n\t\t\t\tprogressed = true;\n\t\t\t}\n\n\t\t\tif ( progressed )\n\t\t\t\tcontinue;\n\n\t\t\tthrow new InvalidOperationException(\n\t\t\t\t$\"The animation host contains a cyclic pose hierarchy near '{pending[0].Name}'.\" );\n\t\t}\n\n\t\treturn model;\n\t}\n\n\tprivate static Transform ComposeLocal( Transform parent, Transform local ) => new(\n\t\tparent.PointToWorld( local.Position ),\n\t\tparent.Rotation * local.Rotation,\n\t\tparent.Scale * local.Scale );\n\n\tprivate static float ScaleRatio( float value, float bindValue )\n\t{\n\t\tif ( !float.IsFinite( value ) )\n\t\t\treturn 1.0f;\n\n\t\treturn float.IsFinite( bindValue ) && MathF.Abs( bindValue ) > 0.000001f\n\t\t\t? value / bindValue\n\t\t\t: value;\n\t}\n\n\tprivate static void ApplyBoneVisibility(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tWeaponAnimationClip clip,\n\t\tfloat time,\n\t\tEvaluatedPose pose )\n\t{\n\t\tforeach ( var part in document.Rig.VisibilityParts.Where( x =>\n\t\t\tx.RenderMode == VisibilityRenderMode.BoneBranch\n\t\t\t&& !WeaponVisibilityEvaluator.Evaluate( x, clip, time ) ) )\n\t\t{\n\t\t\tvar definition = document.Rig.FindBone( part.BoneId )\n\t\t\t\t?? document.Rig.FindBone( part.BoneName );\n\t\t\tvar boneName = definition is not null\n\t\t\t\t&& definition.Id.Equals(\n\t\t\t\t\tdocument.Rig.SourceSkeletonRootId,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t? \"weapon_root\"\n\t\t\t\t\t: definition?.Name ?? part.BoneName;\n\t\t\tif ( !skeleton.ByName.ContainsKey( boneName )\n\t\t\t\t|| !pose.Local.TryGetValue( boneName, out var local ) )\n\t\t\t\tcontinue;\n\n\t\t\t// Some ModelDoc paths normalize animated bone scale. The off-screen translation keeps\n\t\t\t// visibility native and deterministic even when the scale channel is discarded.\n\t\t\tpose.Local[boneName] = local\n\t\t\t\t.WithPosition( local.Position + BoneVisibilitySinkOffset )\n\t\t\t\t.WithScale( local.Scale * 0.0001f );\n\t\t}\n\t}\n\n\tpublic static string WriteReference( HostSkeleton skeleton )\n\t{\n\t\tif ( skeleton.Bones.Count == 0 )\n\t\t\tthrow new InvalidOperationException( \"The animation host skeleton contains no bones.\" );\n\n\t\tvar rootId = Id( \"root\" );\n\t\tvar modelId = Id( \"model\" );\n\t\tvar modelTransformId = Id( \"model-transform\" );\n\t\tvar meshDagId = Id( \"mesh-dag\" );\n\t\tvar meshTransformId = Id( \"mesh-transform\" );\n\t\tvar meshId = Id( \"mesh\" );\n\t\tvar vertexDataId = Id( \"vertex-data\" );\n\t\tvar faceSetId = Id( \"face-set\" );\n\t\tvar materialId = Id( \"material\" );\n\t\tvar compilerBindLocal = skeleton.BuildCompilerBindLocalTransforms();\n\t\tvar builder = new StringBuilder();\n\n\t\tbuilder.AppendLine( \"<!-- dmx encoding keyvalues2 4 format model 22 -->\" );\n\t\tbuilder.AppendLine( \"\\\"DmElement\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", rootId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"root\" );\n\t\tAttribute( builder, 1, \"model\", \"element\", modelId );\n\t\tAttribute( builder, 1, \"skeleton\", \"element\", modelId );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeModel\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", modelId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"weapon_animation_host\" );\n\t\tTransformElement( builder, 1, modelTransformId, \"model\", Transform.Zero );\n\t\tAttribute( builder, 1, \"visible\", \"bool\", \"1\" );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"children\",\n\t\t\tskeleton.Bones\n\t\t\t\t.Where( bone => string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t|| !skeleton.ByName.ContainsKey( bone.ParentName ) )\n\t\t\t\t.Select( bone => JointId( bone.Index ) )\n\t\t\t\t.Append( meshDagId ) );\n\t\tElementArray( builder, 1, \"jointList\", skeleton.Bones.Select( bone => JointId( bone.Index ) ) );\n\t\tAttribute( builder, 1, \"upAxis\", \"string\", \"Z\" );\n\t\tbuilder.AppendLine( \"\\t\\\"axisSystem\\\" \\\"DmeAxisSystem\\\"\" );\n\t\tbuilder.AppendLine( \"\\t{\" );\n\t\tAttribute( builder, 2, \"id\", \"elementid\", Id( \"axis-system\" ) );\n\t\tAttribute( builder, 2, \"name\", \"string\", \"\" );\n\t\tAttribute( builder, 2, \"upAxis\", \"int\", \"3\" );\n\t\tAttribute( builder, 2, \"forwardParity\", \"int\", \"1\" );\n\t\tAttribute( builder, 2, \"coordSys\", \"int\", \"0\" );\n\t\tbuilder.AppendLine( \"\\t}\" );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tforeach ( var bone in skeleton.Bones )\n\t\t\tWriteJoint( builder, skeleton, bone, compilerBindLocal[bone.Name] );\n\n\t\tbuilder.AppendLine( \"\\\"DmeDag\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", meshDagId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"host_reference_triangle\" );\n\t\tTransformElement( builder, 1, meshTransformId, \"host_reference_triangle\", Transform.Zero );\n\t\tAttribute( builder, 1, \"shape\", \"element\", meshId );\n\t\tAttribute( builder, 1, \"visible\", \"bool\", \"1\" );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeMesh\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", meshId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"host_reference_triangle\" );\n\t\tAttribute( builder, 1, \"visible\", \"bool\", \"1\" );\n\t\tAttribute( builder, 1, \"currentState\", \"element\", vertexDataId );\n\t\tElementArray( builder, 1, \"baseStates\", [vertexDataId] );\n\t\tbuilder.AppendLine( \"\\t\\\"faceSets\\\" \\\"element_array\\\"\" );\n\t\tbuilder.AppendLine( \"\\t[\" );\n\t\tbuilder.AppendLine( \"\\t\\t\\\"DmeFaceSet\\\"\" );\n\t\tbuilder.AppendLine( \"\\t\\t{\" );\n\t\tAttribute( builder, 3, \"id\", \"elementid\", faceSetId );\n\t\tAttribute( builder, 3, \"name\", \"string\", CarrierMaterial );\n\t\tIntArray( builder, 3, \"faces\", CarrierFaces( skeleton ) );\n\t\tbuilder.AppendLine( \"\\t\\t\\t\\\"material\\\" \\\"DmeMaterial\\\"\" );\n\t\tbuilder.AppendLine( \"\\t\\t\\t{\" );\n\t\tAttribute( builder, 4, \"id\", \"elementid\", materialId );\n\t\tAttribute( builder, 4, \"name\", \"string\", CarrierMaterial );\n\t\tAttribute( builder, 4, \"mtlName\", \"string\", CarrierMaterial );\n\t\tbuilder.AppendLine( \"\\t\\t\\t}\" );\n\t\tbuilder.AppendLine( \"\\t\\t}\" );\n\t\tbuilder.AppendLine( \"\\t]\" );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tWriteVertexData( builder, vertexDataId, skeleton );\n\t\treturn builder.ToString();\n\t}\n\n\tprivate static void WriteAnimationJoint(\n\t\tStringBuilder builder,\n\t\tHostSkeleton skeleton,\n\t\tHostBone bone,\n\t\tstring prefix )\n\t{\n\t\tbuilder.AppendLine( \"\\\"DmeJoint\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", AnimationJointId( prefix, bone.Index ) );\n\t\tAttribute( builder, 1, \"name\", \"string\", bone.Name );\n\t\tAttribute(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"transform\",\n\t\t\t\"element\",\n\t\t\tAnimationTransformId( prefix, bone.Index ) );\n\t\tAttribute( builder, 1, \"shape\", \"element\", \"\" );\n\t\tAttribute( builder, 1, \"visible\", \"bool\", \"1\" );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"children\",\n\t\t\tskeleton.Bones\n\t\t\t\t.Where( child => child.ParentName.Equals(\n\t\t\t\t\tbone.Name,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t.Select( child => AnimationJointId( prefix, child.Index ) ) );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\t}\n\n\tprivate static void WriteVectorChannel(\n\t\tStringBuilder builder,\n\t\tstring prefix,\n\t\tHostBone bone,\n\t\tstring attribute,\n\t\tstring[] times,\n\t\tstring[] values )\n\t{\n\t\tvar channelId = AnimationChannelId( prefix, bone.Index, attribute );\n\t\tvar logId = Id( $\"{prefix}:log:{bone.Index}:{attribute}\" );\n\t\tvar layerId = Id( $\"{prefix}:layer:{bone.Index}:{attribute}\" );\n\t\tvar transformId = AnimationTransformId( prefix, bone.Index );\n\n\t\tWriteChannelHeader(\n\t\t\tbuilder,\n\t\t\tchannelId,\n\t\t\t$\"{bone.Name}_p\",\n\t\t\ttransformId,\n\t\t\t\"position\",\n\t\t\tlogId );\n\t\tbuilder.AppendLine( \"\\\"DmeVector3Log\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", logId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"vector3 log\" );\n\t\tElementArray( builder, 1, \"layers\", [layerId] );\n\t\tAttribute( builder, 1, \"curveinfo\", \"element\", \"\" );\n\t\tAttribute( builder, 1, \"usedefaultvalue\", \"bool\", \"0\" );\n\t\tAttribute( builder, 1, \"defaultvalue\", \"vector3\", values[0] );\n\t\tTimeArray( builder, 1, \"bookmarksX\", [] );\n\t\tTimeArray( builder, 1, \"bookmarksY\", [] );\n\t\tTimeArray( builder, 1, \"bookmarksZ\", [] );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeVector3LogLayer\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", layerId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"vector3 log\" );\n\t\tTimeArray( builder, 1, \"times\", times );\n\t\tIntArray( builder, 1, \"curvetypes\", [] );\n\t\tVectorArray( builder, 1, \"values\", \"vector3_array\", values );\n\t\tAttribute( builder, 1, \"compressed\", \"binary\", \"\" );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\t}\n\n\tprivate static void WriteQuaternionChannel(\n\t\tStringBuilder builder,\n\t\tstring prefix,\n\t\tHostBone bone,\n\t\tstring[] times,\n\t\tstring[] values )\n\t{\n\t\tvar attribute = \"orientation\";\n\t\tvar channelId = AnimationChannelId( prefix, bone.Index, attribute );\n\t\tvar logId = Id( $\"{prefix}:log:{bone.Index}:{attribute}\" );\n\t\tvar layerId = Id( $\"{prefix}:layer:{bone.Index}:{attribute}\" );\n\t\tvar transformId = AnimationTransformId( prefix, bone.Index );\n\n\t\tWriteChannelHeader(\n\t\t\tbuilder,\n\t\t\tchannelId,\n\t\t\t$\"{bone.Name}_o\",\n\t\t\ttransformId,\n\t\t\tattribute,\n\t\t\tlogId );\n\t\tbuilder.AppendLine( \"\\\"DmeQuaternionLog\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", logId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"quaternion log\" );\n\t\tElementArray( builder, 1, \"layers\", [layerId] );\n\t\tAttribute( builder, 1, \"curveinfo\", \"element\", \"\" );\n\t\tAttribute( builder, 1, \"usedefaultvalue\", \"bool\", \"0\" );\n\t\tAttribute( builder, 1, \"defaultvalue\", \"quaternion\", values[0] );\n\t\tTimeArray( builder, 1, \"bookmarksX\", [] );\n\t\tTimeArray( builder, 1, \"bookmarksY\", [] );\n\t\tTimeArray( builder, 1, \"bookmarksZ\", [] );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeQuaternionLogLayer\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", layerId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"quaternion log\" );\n\t\tTimeArray( builder, 1, \"times\", times );\n\t\tIntArray( builder, 1, \"curvetypes\", [] );\n\t\tVectorArray( builder, 1, \"values\", \"quaternion_array\", values );\n\t\tAttribute( builder, 1, \"compressed\", \"binary\", \"\" );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\t}\n\n\tprivate static void WriteFloatChannel(\n\t\tStringBuilder builder,\n\t\tstring prefix,\n\t\tHostBone bone,\n\t\tstring[] times,\n\t\tstring[] values )\n\t{\n\t\tconst string attribute = \"scale\";\n\t\tvar channelId = AnimationChannelId( prefix, bone.Index, attribute );\n\t\tvar logId = Id( $\"{prefix}:log:{bone.Index}:{attribute}\" );\n\t\tvar layerId = Id( $\"{prefix}:layer:{bone.Index}:{attribute}\" );\n\t\tvar transformId = AnimationTransformId( prefix, bone.Index );\n\n\t\tWriteChannelHeader(\n\t\t\tbuilder,\n\t\t\tchannelId,\n\t\t\t$\"{bone.Name}_s\",\n\t\t\ttransformId,\n\t\t\tattribute,\n\t\t\tlogId );\n\t\tbuilder.AppendLine( \"\\\"DmeFloatLog\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", logId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"float log\" );\n\t\tElementArray( builder, 1, \"layers\", [layerId] );\n\t\tAttribute( builder, 1, \"curveinfo\", \"element\", \"\" );\n\t\tAttribute( builder, 1, \"usedefaultvalue\", \"bool\", \"0\" );\n\t\tAttribute( builder, 1, \"defaultvalue\", \"float\", values[0] );\n\t\tTimeArray( builder, 1, \"bookmarks\", [] );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \"\\\"DmeFloatLogLayer\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", layerId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"float log\" );\n\t\tTimeArray( builder, 1, \"times\", times );\n\t\tIntArray( builder, 1, \"curvetypes\", [] );\n\t\tVectorArray( builder, 1, \"values\", \"float_array\", values );\n\t\tAttribute( builder, 1, \"compressed\", \"binary\", \"\" );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\t}\n\n\tprivate static void WriteChannelHeader(\n\t\tStringBuilder builder,\n\t\tstring channelId,\n\t\tstring name,\n\t\tstring transformId,\n\t\tstring attribute,\n\t\tstring logId )\n\t{\n\t\tbuilder.AppendLine( \"\\\"DmeChannel\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", channelId );\n\t\tAttribute( builder, 1, \"name\", \"string\", name );\n\t\tAttribute( builder, 1, \"fromElement\", \"element\", \"\" );\n\t\tAttribute(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"fromAttribute\",\n\t\t\t\"string\",\n\t\t\tattribute switch\n\t\t\t{\n\t\t\t\t\"position\" => \"valuePosition\",\n\t\t\t\t\"orientation\" => \"valueOrientation\",\n\t\t\t\t_ => \"value\"\n\t\t\t} );\n\t\tAttribute( builder, 1, \"fromIndex\", \"int\", \"0\" );\n\t\tAttribute( builder, 1, \"toElement\", \"element\", transformId );\n\t\tAttribute( builder, 1, \"toAttribute\", \"string\", attribute );\n\t\tAttribute( builder, 1, \"toIndex\", \"int\", \"0\" );\n\t\tAttribute( builder, 1, \"mode\", \"int\", \"1\" );\n\t\tAttribute( builder, 1, \"log\", \"element\", logId );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\t}\n\n\tprivate static void WriteJoint(\n\t\tStringBuilder builder,\n\t\tHostSkeleton skeleton,\n\t\tHostBone bone,\n\t\tTransform bindLocal )\n\t{\n\t\tbuilder.AppendLine( \"\\\"DmeJoint\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", JointId( bone.Index ) );\n\t\tAttribute( builder, 1, \"name\", \"string\", bone.Name );\n\t\tTransformElement(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\tId( $\"joint-transform:{bone.Index}\" ),\n\t\t\tbone.Name,\n\t\t\tbindLocal );\n\t\tAttribute( builder, 1, \"visible\", \"bool\", \"1\" );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"children\",\n\t\t\tskeleton.Bones\n\t\t\t\t.Where( child => child.ParentName.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t.Select( child => JointId( child.Index ) ) );\n\t\tbuilder.AppendLine( \"}\" );\n\t\tbuilder.AppendLine();\n\t}\n\n\tprivate static void WriteVertexData(\n\t\tStringBuilder builder,\n\t\tstring vertexDataId,\n\t\tHostSkeleton skeleton )\n\t{\n\t\tvar positions = new string[skeleton.Bones.Count * 3];\n\t\tvar normals = new string[positions.Length];\n\t\tvar texcoords = new string[positions.Length];\n\t\tvar indices = new int[positions.Length];\n\t\tvar weights = new float[positions.Length];\n\t\tvar blendIndices = new int[positions.Length];\n\n\t\tfor ( var boneIndex = 0; boneIndex < skeleton.Bones.Count; boneIndex++ )\n\t\t{\n\t\t\tvar vertex = boneIndex * 3;\n\n\t\t\t// A tiny weighted triangle keeps each host bone from being culled by ModelDoc.\n\t\t\tpositions[vertex] = \"0 0 0\";\n\t\t\tpositions[vertex + 1] = \"0.001 0 0\";\n\t\t\tpositions[vertex + 2] = \"0 0.001 0\";\n\t\t\tnormals[vertex] = normals[vertex + 1] = normals[vertex + 2] = \"0 0 1\";\n\t\t\ttexcoords[vertex] = \"0 0\";\n\t\t\ttexcoords[vertex + 1] = \"1 0\";\n\t\t\ttexcoords[vertex + 2] = \"0 1\";\n\t\t\tindices[vertex] = vertex;\n\t\t\tindices[vertex + 1] = vertex + 1;\n\t\t\tindices[vertex + 2] = vertex + 2;\n\t\t\tweights[vertex] = weights[vertex + 1] = weights[vertex + 2] = 1.0f;\n\t\t\tblendIndices[vertex] = blendIndices[vertex + 1] = blendIndices[vertex + 2] = boneIndex;\n\t\t}\n\n\t\tbuilder.AppendLine( \"\\\"DmeVertexData\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", vertexDataId );\n\t\tAttribute( builder, 1, \"name\", \"string\", \"bind\" );\n\t\tStringArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\"vertexFormat\",\n\t\t\t[\"position$0\", \"normal$0\", \"texcoord$0\", \"blendweights$0\", \"blendindices$0\"] );\n\t\tAttribute( builder, 1, \"jointCount\", \"int\", \"1\" );\n\t\tAttribute( builder, 1, \"flipVCoordinates\", \"bool\", \"1\" );\n\t\tVectorArray( builder, 1, \"position$0\", \"vector3_array\", positions );\n\t\tIntArray( builder, 1, \"position$0Indices\", indices );\n\t\tVectorArray( builder, 1, \"normal$0\", \"vector3_array\", normals );\n\t\tIntArray( builder, 1, \"normal$0Indices\", indices );\n\t\tVectorArray( builder, 1, \"texcoord$0\", \"vector2_array\", texcoords );\n\t\tIntArray( builder, 1, \"texcoord$0Indices\", indices );\n\t\tFloatArray( builder, 1, \"blendweights$0\", weights );\n\t\tIntArray( builder, 1, \"blendindices$0\", blendIndices );\n\t\tbuilder.AppendLine( \"}\" );\n\t}\n\n\tprivate static int[] CarrierFaces( HostSkeleton skeleton )\n\t{\n\t\tvar faces = new int[skeleton.Bones.Count * 4];\n\t\tfor ( var boneIndex = 0; boneIndex < skeleton.Bones.Count; boneIndex++ )\n\t\t{\n\t\t\tvar vertex = boneIndex * 3;\n\t\t\tvar face = boneIndex * 4;\n\t\t\tfaces[face] = vertex;\n\t\t\tfaces[face + 1] = vertex + 1;\n\t\t\tfaces[face + 2] = vertex + 2;\n\t\t\tfaces[face + 3] = -1;\n\t\t}\n\n\t\treturn faces;\n\t}\n\n\tprivate static void TransformElement(\n\t\tStringBuilder builder,\n\t\tint indent,\n\t\tstring id,\n\t\tstring name,\n\t\tTransform transform )\n\t{\n\t\tvar tabs = new string( '\\t', indent );\n\t\tbuilder.Append( tabs ).AppendLine( \"\\\"transform\\\" \\\"DmeTransform\\\"\" );\n\t\tbuilder.Append( tabs ).AppendLine( \"{\" );\n\t\tAttribute( builder, indent + 1, \"id\", \"elementid\", id );\n\t\tAttribute( builder, indent + 1, \"name\", \"string\", name );\n\t\tAttribute(\n\t\t\tbuilder,\n\t\t\tindent + 1,\n\t\t\t\"position\",\n\t\t\t\"vector3\",\n\t\t\t$\"{F( transform.Position.x )} {F( transform.Position.y )} {F( transform.Position.z )}\" );\n\t\tAttribute(\n\t\t\tbuilder,\n\t\t\tindent + 1,\n\t\t\t\"orientation\",\n\t\t\t\"quaternion\",\n\t\t\t$\"{F( transform.Rotation.x )} {F( transform.Rotation.y )} \"\n\t\t\t+ $\"{F( transform.Rotation.z )} {F( transform.Rotation.w )}\" );\n\t\tAttribute( builder, indent + 1, \"scale\", \"float\", F( transform.Scale.x ) );\n\t\tbuilder.Append( tabs ).AppendLine( \"}\" );\n\t}\n\n\tprivate static void ExternalTransformElement(\n\t\tStringBuilder builder,\n\t\tstring id,\n\t\tstring name,\n\t\tTransform transform )\n\t{\n\t\tbuilder.AppendLine( \"\\\"DmeTransform\\\"\" );\n\t\tbuilder.AppendLine( \"{\" );\n\t\tAttribute( builder, 1, \"id\", \"elementid\", id );\n\t\tAttribute( builder, 1, \"name\", \"string\", name );\n\t\tAttribute( builder, 1, \"position\", \"vector3\", Vector( transform.Position ) );\n\t\tAttribute( builder, 1, \"orientation\", \"quaternion\", Quaternion( transform.Rotation.Normal ) );\n\t\tAttribute( builder, 1, \"scale\", \"float\", F( transform.Scale.x ) );\n\t\tbuilder.AppendLine( \"}\" );\n\t}\n\n\tprivate static void ElementArray(\n\t\tStringBuilder builder,\n\t\tint indent,\n\t\tstring name,\n\t\tSystem.Collections.Generic.IEnumerable<string> values )\n\t{\n\t\tvar tabs = new string( '\\t', indent );\n\t\tvar items = values.ToArray();\n\t\tbuilder.Append( tabs ).Append( '\"' ).Append( name ).AppendLine( \"\\\" \\\"element_array\\\"\" );\n\t\tbuilder.Append( tabs ).AppendLine( \"[\" );\n\t\tfor ( var i = 0; i < items.Length; i++ )\n\t\t{\n\t\t\tbuilder.Append( '\\t', indent + 1 )\n\t\t\t\t.Append( \"\\\"element\\\" \\\"\" )\n\t\t\t\t.Append( Escape( items[i] ) )\n\t\t\t\t.Append( '\"' );\n\t\t\tif ( i < items.Length - 1 )\n\t\t\t\tbuilder.Append( ',' );\n\t\t\tbuilder.AppendLine();\n\t\t}\n\t\tbuilder.Append( tabs ).AppendLine( \"]\" );\n\t}\n\n\tprivate static void StringArray( StringBuilder builder, int indent, string name, string[] values )\n\t{\n\t\tvar tabs = new string( '\\t', indent );\n\t\tbuilder.Append( tabs ).Append( '\"' ).Append( name ).AppendLine( \"\\\" \\\"string_array\\\"\" );\n\t\tbuilder.Append( tabs ).AppendLine( \"[\" );\n\t\tfor ( var i = 0; i < values.Length; i++ )\n\t\t{\n\t\t\tbuilder.Append( '\\t', indent + 1 ).Append( '\"' ).Append( Escape( values[i] ) ).Append( '\"' );\n\t\t\tif ( i < values.Length - 1 )\n\t\t\t\tbuilder.Append( ',' );\n\t\t\tbuilder.AppendLine();\n\t\t}\n\t\tbuilder.Append( tabs ).AppendLine( \"]\" );\n\t}\n\n\tprivate static void IntArray( StringBuilder builder, int indent, string name, int[] values ) =>\n\t\tVectorArray( builder, indent, name, \"int_array\", values.Select( x => x.ToString( Invariant ) ).ToArray() );\n\n\tprivate static void FloatArray( StringBuilder builder, int indent, string name, float[] values ) =>\n\t\tVectorArray( builder, indent, name, \"float_array\", values.Select( F ).ToArray() );\n\n\tprivate static void TimeArray( StringBuilder builder, int indent, string name, string[] values ) =>\n\t\tVectorArray( builder, indent, name, \"time_array\", values );\n\n\tprivate static void VectorArray(\n\t\tStringBuilder builder,\n\t\tint indent,\n\t\tstring name,\n\t\tstring type,\n\t\tstring[] values )\n\t{\n\t\tvar tabs = new string( '\\t', indent );\n\t\tbuilder.Append( tabs ).Append( '\"' ).Append( name ).Append( \"\\\" \\\"\" ).Append( type ).AppendLine( \"\\\"\" );\n\t\tbuilder.Append( tabs ).AppendLine( \"[\" );\n\t\tfor ( var i = 0; i < values.Length; i++ )\n\t\t{\n\t\t\tbuilder.Append( '\\t', indent + 1 ).Append( '\"' ).Append( values[i] ).Append( '\"' );\n\t\t\tif ( i < values.Length - 1 )\n\t\t\t\tbuilder.Append( ',' );\n\t\t\tbuilder.AppendLine();\n\t\t}\n\t\tbuilder.Append( tabs ).AppendLine( \"]\" );\n\t}\n\n\tprivate static void Attribute(\n\t\tStringBuilder builder,\n\t\tint indent,\n\t\tstring name,\n\t\tstring type,\n\t\tstring value )\n\t{\n\t\tbuilder.Append( '\\t', indent )\n\t\t\t.Append( '\"' ).Append( name ).Append( '\"' );\n\t\tif ( !string.IsNullOrWhiteSpace( type ) )\n\t\t\tbuilder.Append( \" \\\"\" ).Append( type ).Append( '\"' );\n\t\tbuilder.Append( \" \\\"\" ).Append( Escape( value ) ).AppendLine( \"\\\"\" );\n\t}\n\n\tprivate static string JointId( int index ) => Id( $\"joint:{index}\" );\n\tprivate static string AnimationJointId( string prefix, int index ) =>\n\t\tId( $\"{prefix}:joint:{index}\" );\n\tprivate static string AnimationTransformId( string prefix, int index ) =>\n\t\tId( $\"{prefix}:transform:{index}\" );\n\tprivate static string AnimationBaseTransformId( string prefix, int index ) =>\n\t\tId( $\"{prefix}:base-transform:{index}\" );\n\tprivate static string AnimationChannelId( string prefix, int index, string attribute ) =>\n\t\tId( $\"{prefix}:channel:{index}:{attribute}\" );\n\n\tprivate static string Id( string key )\n\t{\n\t\tvar bytes = SHA256.HashData( Encoding.UTF8.GetBytes( $\"SboxWeaponAnimator.DmxReference:{key}\" ) );\n\t\treturn new Guid( bytes.AsSpan( 0, 16 ) ).ToString();\n\t}\n\n\tprivate static string F( float value ) => value.ToString( \"0.######\", Invariant );\n\tprivate static string Vector( Vector3 value ) =>\n\t\t$\"{F( value.x )} {F( value.y )} {F( value.z )}\";\n\tprivate static string Quaternion( Rotation value ) =>\n\t\t$\"{F( value.x )} {F( value.y )} {F( value.z )} {F( value.w )}\";\n\tprivate static string Escape( string value ) => value.Replace( \"\\\\\", \"\\\\\\\\\" ).Replace( \"\\\"\", \"\\\\\\\"\" );\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Widgets/CalibrationWorkspacePanels.cs",
            "FileName": "CalibrationWorkspacePanels.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing System.Text;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic enum ViewportPickMode\n{\n\tNone,\n\tMeasurementFirst,\n\tMeasurementSecond,\n\tGripAnchor,\n\tRearBoreAnchor,\n\tFrontBoreAnchor,\n\tMuzzleAnchor,\n\tEjectAnchor,\n\tCustomAnchor\n}\n\npublic static class CalibrationSelection\n{\n\tprivate const string AnchorPrefix = \"@anchor:\";\n\n\tpublic static string Anchor( AnchorKind kind ) => $\"{AnchorPrefix}{kind}\";\n\n\t/// <summary>\n\t/// Custom anchors carry their id in the token, because a weapon may hold several of them.\n\t/// </summary>\n\tpublic static string Anchor( WeaponAnchor anchor ) =>\n\t\tanchor.Kind == AnchorKind.Custom\n\t\t\t? $\"{AnchorPrefix}{AnchorKind.Custom}:{anchor.Id:N}\"\n\t\t\t: Anchor( anchor.Kind );\n\n\tpublic static string DisplayName( AnchorKind kind ) => kind switch\n\t{\n\t\tAnchorKind.Grip => \"Primary grip\",\n\t\tAnchorKind.RearBore => \"Alignment marker \u2014 rear\",\n\t\tAnchorKind.FrontBore => \"Alignment marker \u2014 front\",\n\t\tAnchorKind.Muzzle => \"Muzzle\",\n\t\tAnchorKind.Eject => \"Eject\",\n\t\t_ => \"Custom anchor\"\n\t};\n\n\tpublic static string DisplayName( WeaponAnchor anchor ) =>\n\t\tanchor.Kind == AnchorKind.Custom && !string.IsNullOrWhiteSpace( anchor.Name )\n\t\t\t? anchor.Name\n\t\t\t: DisplayName( anchor.Kind );\n\n\tpublic static bool TryGetAnchor( string control, out AnchorKind kind )\n\t{\n\t\tkind = default;\n\t\tif ( !control.StartsWith( AnchorPrefix, StringComparison.Ordinal ) )\n\t\t\treturn false;\n\t\tvar body = control[AnchorPrefix.Length..];\n\t\tvar separator = body.IndexOf( ':' );\n\t\treturn Enum.TryParse( separator >= 0 ? body[..separator] : body, out kind );\n\t}\n\n\tpublic static bool TryGetCustomAnchorId( string control, out Guid id )\n\t{\n\t\tid = Guid.Empty;\n\t\tif ( !control.StartsWith( AnchorPrefix, StringComparison.Ordinal ) )\n\t\t\treturn false;\n\t\tvar body = control[AnchorPrefix.Length..];\n\t\tvar separator = body.IndexOf( ':' );\n\t\treturn separator >= 0\n\t\t\t&& Guid.TryParseExact( body[(separator + 1)..], \"N\", out id );\n\t}\n\n\t/// <summary>\n\t/// Resolves a selection token to its anchor, by id for custom anchors and by kind otherwise.\n\t/// </summary>\n\tpublic static WeaponAnchor? Resolve( WeaponAnimationDocument document, string control ) =>\n\t\tTryGetCustomAnchorId( control, out var id )\n\t\t\t? document.Calibration.FindAnchor( id )\n\t\t\t: TryGetAnchor( control, out var kind )\n\t\t\t\t? document.Calibration.GetAnchor( kind )\n\t\t\t\t: null;\n}\n\ninternal sealed class ScrubHandle : Widget\n{\n\tprivate readonly string _text;\n\tprivate readonly Color _accent;\n\tprivate readonly float _sensitivity;\n\tprivate readonly Func<float> _getValue;\n\tprivate readonly Action _begin;\n\tprivate readonly Action<float> _preview;\n\tprivate readonly Action _end;\n\tprivate bool _dragging;\n\tprivate float _startX;\n\tprivate float _startValue;\n\n\tpublic ScrubHandle(\n\t\tstring text,\n\t\tColor accent,\n\t\tfloat sensitivity,\n\t\tFunc<float> getValue,\n\t\tAction begin,\n\t\tAction<float> preview,\n\t\tAction end,\n\t\tWidget parent ) : base( parent )\n\t{\n\t\t_text = text;\n\t\t_accent = accent;\n\t\t_sensitivity = sensitivity;\n\t\t_getValue = getValue;\n\t\t_begin = begin;\n\t\t_preview = preview;\n\t\t_end = end;\n\t\tFixedWidth = 20;\n\t\tFixedHeight = 26;\n\t\tMouseTracking = true;\n\t\tCursor = CursorShape.SizeH;\n\t\tToolTip = $\"Drag {_text} horizontally to adjust\";\n\t}\n\n\tprotected override void OnMousePress( MouseEvent e )\n\t{\n\t\tif ( !e.LeftMouseButton )\n\t\t{\n\t\t\tbase.OnMousePress( e );\n\t\t\treturn;\n\t\t}\n\n\t\t_dragging = true;\n\t\t_startX = e.ScreenPosition.x;\n\t\t_startValue = _getValue();\n\t\t_begin();\n\t\te.Accepted = true;\n\t}\n\n\tprotected override void OnMouseMove( MouseEvent e )\n\t{\n\t\tif ( !_dragging )\n\t\t{\n\t\t\tbase.OnMouseMove( e );\n\t\t\treturn;\n\t\t}\n\n\t\t_preview( _startValue + (e.ScreenPosition.x - _startX) * _sensitivity );\n\t\te.Accepted = true;\n\t}\n\n\tprotected override void OnMouseReleased( MouseEvent e )\n\t{\n\t\tif ( !_dragging || e.Button != MouseButtons.Left )\n\t\t{\n\t\t\tbase.OnMouseReleased( e );\n\t\t\treturn;\n\t\t}\n\n\t\t_dragging = false;\n\t\t_end();\n\t\te.Accepted = true;\n\t}\n\n\tprotected override void OnPaint()\n\t{\n\t\tPaint.Antialiasing = true;\n\t\tPaint.ClearPen();\n\t\tPaint.SetBrush( _accent.WithAlpha( Paint.HasPressed ? 0.42f : Paint.HasMouseOver ? 0.32f : 0.22f ) );\n\t\tPaint.DrawRect( LocalRect, 3 );\n\t\tPaint.SetPen( _accent.Lighten( 0.25f ) );\n\t\tPaint.SetDefaultFont( 10, 650 );\n\t\tPaint.DrawText( LocalRect, _text, TextFlag.Center );\n\t}\n}\n\npublic sealed class RigAuditPanel : Widget\n{\n\tprivate readonly WeaponAnimatorController _controller;\n\tprivate readonly ScrollArea _boneScroll;\n\tprivate readonly Widget _boneCanvas;\n\tprivate readonly LineEdit _search;\n\tprivate readonly Label _sourceStatus;\n\tprivate readonly Label _retainedStatus;\n\tprivate readonly Dictionary<string, WeaponAnimatorButton> _boneButtons =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate string _lastSelectedBone = \"\";\n\tprivate string _boneStructureSignature = \"\";\n\tprivate string _filter = \"\";\n\tprivate bool _showMovable = true;\n\tprivate bool _showStructural = true;\n\tprivate bool _showIgnored;\n\n\tpublic event Action? ImportRequested;\n\tpublic event Action? RigReviewConfirmed;\n\n\tpublic RigAuditPanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )\n\t{\n\t\t_controller = controller;\n\t\tLayout = Layout.Column();\n\t\tLayout.Margin = new Sandbox.UI.Margin( 8 );\n\t\tLayout.Spacing = 6;\n\n\t\tvar import = WeaponAnimatorTheme.Button(\n\t\t\t\"Import rigged model\",\n\t\t\t\"file_upload\",\n\t\t\t() => ImportRequested?.Invoke(),\n\t\t\tthis,\n\t\t\ttrue );\n\t\tLayout.Add( import );\n\n\t\t_sourceStatus = WeaponAnimatorTheme.Label( \"No source selected\", this, true );\n\t\t_sourceStatus.WordWrap = true;\n\t\tLayout.Add( _sourceStatus );\n\n\t\t_retainedStatus = WeaponAnimatorTheme.Label( \"No weapon subtree selected\", this, true );\n\t\t_retainedStatus.WordWrap = true;\n\t\tLayout.Add( _retainedStatus );\n\n\t\t_search = new LineEdit( this )\n\t\t{\n\t\t\tPlaceholderText = \"Search bones\u2026\",\n\t\t\tFixedHeight = 28\n\t\t};\n\t\t_search.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t_search.TextEdited += value =>\n\t\t{\n\t\t\t_filter = value?.Trim() ?? \"\";\n\t\t\tRebuildBones();\n\t\t};\n\t\tLayout.Add( _search );\n\n\t\tvar filters = Row( this );\n\t\tfilters.Layout.Add( Toggle( filters, \"Movable\", _showMovable, x => _showMovable = x ) );\n\t\tfilters.Layout.Add( Toggle( filters, \"Structural\", _showStructural, x => _showStructural = x ) );\n\t\tfilters.Layout.Add( Toggle( filters, \"Ignored\", _showIgnored, x => _showIgnored = x ) );\n\t\tLayout.Add( filters );\n\n\t\tvar rootActions = Row( this );\n\t\trootActions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Set selected as weapon root\",\n\t\t\t\"account_tree\",\n\t\t\tSetSelectedWeaponRoot,\n\t\t\trootActions ), 1 );\n\t\tLayout.Add( rootActions );\n\n\t\tvar branchActions = Row( this );\n\t\tbranchActions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Include branch\",\n\t\t\t\"add\",\n\t\t\tIncludeSelectedBranch,\n\t\t\tbranchActions ) );\n\t\tbranchActions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Exclude branch\",\n\t\t\t\"remove\",\n\t\t\tExcludeSelectedBranch,\n\t\t\tbranchActions ) );\n\t\tLayout.Add( branchActions );\n\n\t\t_boneScroll = new ScrollArea( this );\n\t\t_boneCanvas = new Widget( _boneScroll );\n\t\t_boneCanvas.Layout = Layout.Column();\n\t\t_boneCanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();\n\t\t_boneCanvas.Layout.Spacing = 2;\n\t\t_boneScroll.Canvas = _boneCanvas;\n\t\tLayout.Add( _boneScroll, 1 );\n\n\t\tLayout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Confirm weapon bones\",\n\t\t\t\"verified\",\n\t\t\tConfirmWeaponBones,\n\t\t\tthis,\n\t\t\ttrue ) );\n\n\t\t_controller.DocumentChanged += Refresh;\n\t\t_controller.SelectionChanged += RefreshBoneSelection;\n\t\tRefresh();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t_controller.DocumentChanged -= Refresh;\n\t\t_controller.SelectionChanged -= RefreshBoneSelection;\n\t\tbase.OnDestroyed();\n\t}\n\n\tpublic void Refresh()\n\t{\n\t\tvar source = _controller.Document.Source;\n\t\t_sourceStatus.Text = string.IsNullOrWhiteSpace( source.SourcePath )\n\t\t\t? \"No source selected\"\n\t\t\t: $\"{source.SourcePath}\\n{_controller.Document.Rig.Bones.Count} bones \u00b7 \"\n\t\t\t\t+ $\"{source.Materials.Count( material => material.HasUsableTextures )}/\"\n\t\t\t\t+ $\"{source.Materials.Count} textured materials \u00b7 \"\n\t\t\t\t+ $\"{(source.Compiled ? \"compiled\" : \"compile failed\")}\";\n\t\tvar rig = _controller.Document.Rig;\n\t\tvar retained = rig.Bones.Count( WeaponRigHierarchy.IsRetained );\n\t\tvar structural = rig.Bones.Count( x =>\n\t\t\tx.Inclusion == WeaponBoneInclusion.StructuralBridge\n\t\t\t|| x.Classification == WeaponBoneClassification.Structural );\n\t\tvar excluded = rig.Bones.Count - retained;\n\t\t_retainedStatus.Text = rig.Bones.Count == 0\n\t\t\t? \"No weapon subtree selected\"\n\t\t\t: $\"{retained} retained \u00b7 {structural} structural \u00b7 {excluded} excluded\"\n\t\t\t\t+ (rig.ReviewRequired ? \"\\nReview and confirm the filtered weapon preview.\" : \"\\nWeapon bones confirmed.\")\n\t\t\t\t+ (rig.Bones.Any( x =>\n\t\t\t\t\t\tx.Inclusion == WeaponBoneInclusion.Excluded && x.HasSkinInfluence )\n\t\t\t\t\t? \"\\nExcluded branches may affect visible geometry; verify the model before confirming.\"\n\t\t\t\t\t: \"\");\n\t\tvar signature = BoneStructureSignature(\n\t\t\trig,\n\t\t\t_filter,\n\t\t\t_showMovable,\n\t\t\t_showStructural,\n\t\t\t_showIgnored );\n\t\tif ( signature != _boneStructureSignature )\n\t\t\tRebuildBones();\n\t\telse\n\t\t\tRefreshBoneSelection();\n\t}\n\n\tprivate void RebuildBones()\n\t{\n\t\tif ( _boneCanvas is null )\n\t\t\treturn;\n\t\t_boneCanvas.Layout.Clear( true );\n\t\t_boneButtons.Clear();\n\t\tvar bonesByName = _controller.Document.Rig.Bones\n\t\t\t.GroupBy( x => x.Name, StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToDictionary(\n\t\t\t\tx => x.Key,\n\t\t\t\tx => x.First(),\n\t\t\t\tStringComparer.OrdinalIgnoreCase );\n\t\tvar depths = new Dictionary<string, int>( StringComparer.OrdinalIgnoreCase );\n\n\t\tint ResolveDepth( WeaponBoneDefinition bone, HashSet<string> visiting )\n\t\t{\n\t\t\tif ( depths.TryGetValue( bone.Name, out var cached ) )\n\t\t\t\treturn cached;\n\t\t\tif ( !visiting.Add( bone.Name )\n\t\t\t\t|| string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t|| !bonesByName.TryGetValue( bone.ParentName, out var parent ) )\n\t\t\t\treturn depths[bone.Name] = 0;\n\t\t\tvar depth = Math.Min( ResolveDepth( parent, visiting ) + 1, 16 );\n\t\t\tvisiting.Remove( bone.Name );\n\t\t\treturn depths[bone.Name] = depth;\n\t\t}\n\n\t\tforeach ( var bone in _controller.Document.Rig.Bones.Where( IsVisible ) )\n\t\t{\n\t\t\tvar row = Row( _boneCanvas );\n\t\t\trow.FixedHeight = 28;\n\t\t\tvar depth = ResolveDepth( bone, [] );\n\t\t\tvar name = new WeaponAnimatorButton( $\"{new string( ' ', Math.Min( depth, 8 ) * 2 )}{bone.Name}\", row )\n\t\t\t{\n\t\t\t\tClicked = () => _controller.SelectBone( bone.Name ),\n\t\t\t\tTint = WeaponAnimatorTheme.Surface\n\t\t\t};\n\t\t\t_boneButtons[bone.Name] = name;\n\t\t\trow.Layout.Add( name, 1 );\n\n\t\t\tvar classification = new WeaponAnimatorButton( ShortClassification( bone.Classification ), row )\n\t\t\t{\n\t\t\t\tClicked = () => CycleClassification( bone ),\n\t\t\t\tFixedWidth = 34,\n\t\t\t\tToolTip = $\"{bone.Classification} \u00b7 {bone.Inclusion}\",\n\t\t\t\tTint = bone.Classification switch\n\t\t\t\t{\n\t\t\t\t\tWeaponBoneClassification.WeaponRoot => WeaponAnimatorTheme.Coral * 0.55f,\n\t\t\t\t\tWeaponBoneClassification.Animatable => WeaponAnimatorTheme.Amber * 0.45f,\n\t\t\t\t\tWeaponBoneClassification.Structural => WeaponAnimatorTheme.SurfaceRaised,\n\t\t\t\t\t_ => WeaponAnimatorTheme.Background\n\t\t\t\t}\n\t\t\t};\n\t\t\trow.Layout.Add( classification );\n\t\t\t_boneCanvas.Layout.Add( row );\n\t\t}\n\n\t\t_boneCanvas.Layout.AddStretchCell();\n\t\t_boneStructureSignature = BoneStructureSignature(\n\t\t\t_controller.Document.Rig,\n\t\t\t_filter,\n\t\t\t_showMovable,\n\t\t\t_showStructural,\n\t\t\t_showIgnored );\n\t\t_lastSelectedBone = \"\";\n\t\tRefreshBoneSelection();\n\t}\n\n\tinternal static string BoneStructureSignature(\n\t\tWeaponRigDefinition rig,\n\t\tstring filter,\n\t\tbool showMovable,\n\t\tbool showStructural,\n\t\tbool showIgnored )\n\t{\n\t\tvar signature = new StringBuilder()\n\t\t\t.Append( filter )\n\t\t\t.Append( '|' )\n\t\t\t.Append( showMovable ? '1' : '0' )\n\t\t\t.Append( showStructural ? '1' : '0' )\n\t\t\t.Append( showIgnored ? '1' : '0' );\n\n\t\tforeach ( var bone in rig.Bones )\n\t\t{\n\t\t\tsignature\n\t\t\t\t.Append( '\\n' )\n\t\t\t\t.Append( bone.Id )\n\t\t\t\t.Append( '\\t' )\n\t\t\t\t.Append( bone.Name )\n\t\t\t\t.Append( '\\t' )\n\t\t\t\t.Append( bone.ParentId )\n\t\t\t\t.Append( '\\t' )\n\t\t\t\t.Append( bone.ParentName )\n\t\t\t\t.Append( '\\t' )\n\t\t\t\t.Append( (int)bone.Classification )\n\t\t\t\t.Append( '\\t' )\n\t\t\t\t.Append( (int)bone.Inclusion );\n\t\t}\n\n\t\treturn signature.ToString();\n\t}\n\n\tprivate void RefreshBoneSelection()\n\t{\n\t\tvar selected = _controller.Document.Workspace.SelectedBone;\n\t\tif ( _lastSelectedBone.Equals( selected, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn;\n\t\tif ( _boneButtons.TryGetValue( _lastSelectedBone, out var previous ) )\n\t\t\tprevious.Tint = WeaponAnimatorTheme.Surface;\n\t\tif ( _boneButtons.TryGetValue( selected, out var current ) )\n\t\t{\n\t\t\tcurrent.Tint = WeaponAnimatorTheme.Cyan * 0.45f;\n\t\t\tRevealIfNeeded( current );\n\t\t}\n\t\t_lastSelectedBone = selected;\n\t}\n\n\t/// <summary>\n\t/// Scrolls a bone selected elsewhere - the viewport, most often - into view, so picking a bone\n\t/// in the scene does not leave the list parked somewhere else.\n\t/// </summary>\n\tprivate void RevealIfNeeded( Widget button )\n\t{\n\t\tif ( button.Height <= 0 || _boneScroll.Height <= 0 )\n\t\t\treturn;\n\n\t\tvar viewportTop = _boneScroll.ScreenPosition.y;\n\t\tvar viewportBottom = viewportTop + _boneScroll.Height;\n\t\tvar itemTop = button.ScreenPosition.y;\n\t\tvar itemBottom = itemTop + button.Height;\n\t\tif ( itemTop < viewportTop )\n\t\t\t_boneScroll.VerticalScrollbar.Value -= (viewportTop - itemTop).CeilToInt();\n\t\telse if ( itemBottom > viewportBottom )\n\t\t\t_boneScroll.VerticalScrollbar.Value += (itemBottom - viewportBottom).CeilToInt();\n\t}\n\n\tprivate bool IsVisible( WeaponBoneDefinition bone )\n\t{\n\t\tif ( !string.IsNullOrWhiteSpace( _filter )\n\t\t\t&& !bone.Name.Contains( _filter, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn false;\n\n\t\treturn bone.Classification switch\n\t\t{\n\t\t\tWeaponBoneClassification.WeaponRoot => _showMovable,\n\t\t\tWeaponBoneClassification.Animatable => _showMovable,\n\t\t\tWeaponBoneClassification.Structural => _showStructural,\n\t\t\tWeaponBoneClassification.Ignored => _showIgnored,\n\t\t\t_ => true\n\t\t};\n\t}\n\n\tprivate void CycleClassification( WeaponBoneDefinition selected )\n\t{\n\t\tif ( selected.Inclusion == WeaponBoneInclusion.Excluded\n\t\t\t|| selected.Classification == WeaponBoneClassification.WeaponRoot )\n\t\t\treturn;\n\n\t\tvar next = selected.Classification switch\n\t\t{\n\t\t\tWeaponBoneClassification.Animatable => WeaponBoneClassification.Structural,\n\t\t\t_ => WeaponBoneClassification.Animatable\n\t\t};\n\n\t\t_controller.Mutate( $\"Classify {selected.Name}\", document =>\n\t\t{\n\t\t\tselected.Classification = next;\n\t\t\tdocument.Rig.ReviewRequired = true;\n\t\t\tdocument.Rig.FilteredPreviewConfirmed = false;\n\t\t\tdocument.Source.PreviewHostCompiled = false;\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\tprivate void SetSelectedWeaponRoot()\n\t{\n\t\tvar selected = _controller.Document.Workspace.SelectedBone;\n\t\tif ( string.IsNullOrWhiteSpace( selected ) )\n\t\t\treturn;\n\t\t_controller.Mutate( $\"Set weapon root {selected}\", document =>\n\t\t{\n\t\t\tWeaponRigHierarchy.SelectWeaponSubtree( document.Rig, selected );\n\t\t\tdocument.Source.PreviewHostCompiled = false;\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\tprivate void ExcludeSelectedBranch()\n\t{\n\t\tvar selected = _controller.Document.Workspace.SelectedBone;\n\t\tif ( string.IsNullOrWhiteSpace( selected ) )\n\t\t\treturn;\n\t\t_controller.Mutate( $\"Exclude branch {selected}\", document =>\n\t\t{\n\t\t\tif ( !WeaponRigHierarchy.ExcludeBranch( document.Rig, selected ) )\n\t\t\t\treturn;\n\t\t\tdocument.Source.PreviewHostCompiled = false;\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\tprivate void IncludeSelectedBranch()\n\t{\n\t\tvar selected = _controller.Document.Workspace.SelectedBone;\n\t\tif ( string.IsNullOrWhiteSpace( selected ) )\n\t\t\treturn;\n\t\t_controller.Mutate( $\"Include branch {selected}\", document =>\n\t\t{\n\t\t\tif ( !WeaponRigHierarchy.IncludeBranch( document.Rig, selected ) )\n\t\t\t\treturn;\n\t\t\tdocument.Source.PreviewHostCompiled = false;\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\tprivate void ConfirmWeaponBones()\n\t{\n\t\tif ( _controller.Document.Rig.Bones.Count == 0 )\n\t\t\treturn;\n\t\t_controller.Mutate( \"Confirm filtered weapon bones\", document =>\n\t\t{\n\t\t\tWeaponRigHierarchy.ConfirmFilteredPreview( document.Rig );\n\t\t\tdocument.Rig.ProfileHash = WeaponSourceImporter.HashText(\n\t\t\t\tWeaponRigHierarchy.ProfileText( document.Rig ) );\n\t\t\tdocument.Source.PreviewHostCompiled = false;\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t\tRigReviewConfirmed?.Invoke();\n\t}\n\n\tprivate static string ShortClassification( WeaponBoneClassification value ) => value switch\n\t{\n\t\tWeaponBoneClassification.WeaponRoot => \"R\",\n\t\tWeaponBoneClassification.Animatable => \"A\",\n\t\tWeaponBoneClassification.Structural => \"S\",\n\t\t_ => \"\u00d7\"\n\t};\n\n\tprivate Button Toggle( Widget parent, string text, bool value, Action<bool> changed )\n\t{\n\t\tvar button = new WeaponAnimatorButton( text, parent )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tIsChecked = value,\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Toggled = () =>\n\t\t{\n\t\t\tchanged( button.IsChecked );\n\t\t\tRebuildBones();\n\t\t};\n\t\treturn button;\n\t}\n\n\tinternal static Widget Row( Widget parent )\n\t{\n\t\tvar row = new Widget( parent );\n\t\trow.SetStyles( \"background-color: transparent; border: none;\" );\n\t\trow.Layout = Layout.Row();\n\t\trow.Layout.Margin = 0;\n\t\trow.Layout.Spacing = 4;\n\t\treturn row;\n\t}\n}\n\npublic sealed class CalibrationInspectorPanel : Widget\n{\n\tprivate readonly WeaponAnimatorController _controller;\n\tprivate readonly Label _selectedBone;\n\tprivate readonly Label _measurementResult;\n\tprivate readonly Label _alignmentResult;\n\tprivate readonly Label _confirmationState;\n\tprivate readonly LineEdit _knownDistance;\n\tprivate readonly List<Action> _transformRefreshers = [];\n\tprivate readonly List<Action> _documentRefreshers = [];\n\tprivate readonly Dictionary<Guid, Button> _customAnchorButtons = [];\n\tprivate Widget? _customAnchorCanvas;\n\tprivate string _customAnchorSignature = \"\";\n\tprivate Vector3 _modelDimensions;\n\n\tpublic event Action<ViewportPickMode, Guid>? PickRequested;\n\tpublic event Action? AutoAlignRequested;\n\tpublic event Action? ConfirmRequested;\n\tpublic event Action? RebuildPreviewRequested;\n\n\tpublic CalibrationInspectorPanel(\n\t\tWeaponAnimatorController controller,\n\t\tWidget? parent = null ) : base( parent )\n\t{\n\t\t_controller = controller;\n\t\tvar scroll = new ScrollArea( this );\n\t\tLayout = Layout.Column();\n\t\tLayout.Margin = 0;\n\t\tLayout.Add( scroll, 1 );\n\n\t\tvar canvas = new Widget( scroll );\n\t\tcanvas.Layout = Layout.Column();\n\t\tcanvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin( 10 );\n\t\tcanvas.Layout.Spacing = 8;\n\t\tscroll.Canvas = canvas;\n\n\t\tcanvas.Layout.Add( Section( canvas, \"SELECTION\" ) );\n\t\t_selectedBone = WeaponAnimatorTheme.Label( \"No bone selected\", canvas, true );\n\t\tcanvas.Layout.Add( _selectedBone );\n\n\t\tcanvas.Layout.Add( Section( canvas, \"PHYSICAL MEASUREMENT\" ) );\n\t\tvar pickRow = RigAuditPanel.Row( canvas );\n\t\tpickRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Point A\",\n\t\t\t\"looks_one\",\n\t\t\t() => PickRequested?.Invoke( ViewportPickMode.MeasurementFirst, Guid.Empty ),\n\t\t\tpickRow ), 1 );\n\t\tpickRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Point B\",\n\t\t\t\"looks_two\",\n\t\t\t() => PickRequested?.Invoke( ViewportPickMode.MeasurementSecond, Guid.Empty ),\n\t\t\tpickRow ), 1 );\n\t\tcanvas.Layout.Add( pickRow );\n\n\t\tvar knownRow = RigAuditPanel.Row( canvas );\n\t\t_knownDistance = new LineEdit( knownRow )\n\t\t{\n\t\t\tPlaceholderText = \"Known distance\",\n\t\t\tFixedHeight = 28\n\t\t};\n\t\t_knownDistance.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t// EditingFinished fires on focus loss; ReturnPressed covers Enter explicitly so a typed\n\t\t// distance is committed even where the blur signal does not reach us.\n\t\t_knownDistance.EditingFinished += CommitScalePreview;\n\t\t_knownDistance.ReturnPressed += CommitScalePreview;\n\t\tknownRow.Layout.Add( _knownDistance, 1 );\n\n\t\tvar unitButton = new WeaponAnimatorButton( \"in\", knownRow )\n\t\t{\n\t\t\tFixedWidth = 48,\n\t\t\tClicked = ToggleUnit,\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tknownRow.Layout.Add( unitButton );\n\t\t_documentRefreshers.Add( () =>\n\t\t{\n\t\t\tunitButton.Text = _controller.Document.Calibration.Measurement.Unit\n\t\t\t\t== MeasurementUnit.Inches\n\t\t\t\t\t? \"in\"\n\t\t\t\t\t: \"cm\";\n\t\t} );\n\t\tcanvas.Layout.Add( knownRow );\n\n\t\t_measurementResult = WeaponAnimatorTheme.Label( \"Pick two points to establish scale.\", canvas, true );\n\t\t_measurementResult.WordWrap = true;\n\t\tcanvas.Layout.Add( _measurementResult );\n\t\tcanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Apply scale\",\n\t\t\t\"done\",\n\t\t\tApplyScale,\n\t\t\tcanvas,\n\t\t\ttrue ) );\n\n\t\tcanvas.Layout.Add( Section( canvas, \"AUTO-ALIGN MARKERS \u00b7 OPTIONAL\" ) );\n\t\tvar alignmentNote = WeaponAnimatorTheme.Label(\n\t\t\t\"Only needed when Auto-align should rotate an incorrectly oriented source model.\",\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\talignmentNote.WordWrap = true;\n\t\tcanvas.Layout.Add( alignmentNote );\n\t\tAddPickButton( canvas, \"Alignment marker \u2014 rear\", \"radio_button_unchecked\", ViewportPickMode.RearBoreAnchor );\n\t\tAddPickButton( canvas, \"Alignment marker \u2014 front\", \"adjust\", ViewportPickMode.FrontBoreAnchor );\n\t\tvar autoAlign = WeaponAnimatorTheme.Button(\n\t\t\t\"Auto-align from markers\",\n\t\t\t\"center_focus_strong\",\n\t\t\t() => AutoAlignRequested?.Invoke(),\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\tautoAlign.ToolTip = \"Uses the rear-to-front marker direction to rotate and place the weapon\";\n\t\tcanvas.Layout.Add( autoAlign );\n\t\t_alignmentResult = WeaponAnimatorTheme.Label(\n\t\t\t\"Skip these markers when the source orientation is already correct.\",\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\t_alignmentResult.WordWrap = true;\n\t\tcanvas.Layout.Add( _alignmentResult );\n\n\t\tcanvas.Layout.Add( Section( canvas, \"GRIP ANCHOR \u00b7 REQUIRED\" ) );\n\t\tvar gripNote = WeaponAnimatorTheme.Label(\n\t\t\t\"Seeds the default primary-hand target used on the animation page.\",\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\tgripNote.WordWrap = true;\n\t\tcanvas.Layout.Add( gripNote );\n\t\tAddPickButton( canvas, \"Primary grip\", \"pan_tool\", ViewportPickMode.GripAnchor );\n\n\t\tcanvas.Layout.Add( Section( canvas, \"OUTPUT ANCHORS \u00b7 OPTIONAL\" ) );\n\t\tAddPickButton( canvas, \"Muzzle\", \"flare\", ViewportPickMode.MuzzleAnchor );\n\t\tAddPickButton( canvas, \"Eject\", \"outbound\", ViewportPickMode.EjectAnchor );\n\n\t\tcanvas.Layout.Add( Section( canvas, \"CUSTOM ANCHORS \u00b7 OPTIONAL\" ) );\n\t\tvar customNote = WeaponAnimatorTheme.Label(\n\t\t\t\"Exported alongside muzzle and eject. Name each one to match the attachment your game \"\n\t\t\t\t+ \"code expects. Grip and alignment markers stay in calibration and are never exported.\",\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\tcustomNote.WordWrap = true;\n\t\tcanvas.Layout.Add( customNote );\n\t\t_customAnchorCanvas = new Widget( canvas );\n\t\t_customAnchorCanvas.Layout = Layout.Column();\n\t\t_customAnchorCanvas.Layout.Margin = 0;\n\t\t_customAnchorCanvas.Layout.Spacing = 3;\n\t\tcanvas.Layout.Add( _customAnchorCanvas );\n\t\tcanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Add custom anchor\",\n\t\t\t\"add_location_alt\",\n\t\t\tAddCustomAnchor,\n\t\t\tcanvas ) );\n\t\t_documentRefreshers.Add( RefreshCustomAnchors );\n\n\t\tcanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Clear all anchors\",\n\t\t\t\"delete_sweep\",\n\t\t\tClearAllAnchors,\n\t\t\tcanvas ) );\n\t\t_documentRefreshers.Add( () =>\n\t\t{\n\t\t\tvar calibration = _controller.Document.Calibration;\n\t\t\tautoAlign.Enabled = calibration.GetAnchor( AnchorKind.Grip ) is not null\n\t\t\t\t&& calibration.GetAnchor( AnchorKind.RearBore ) is not null\n\t\t\t\t&& calibration.GetAnchor( AnchorKind.FrontBore ) is not null;\n\t\t} );\n\n\t\tcanvas.Layout.Add( Section( canvas, \"NUMERIC TRANSFORMS\" ) );\n\t\tAddTransformFields( canvas, \"Physical\", false );\n\t\tAddTransformFields( canvas, \"Viewmodel framing\", true );\n\n\t\tcanvas.Layout.Add( Section( canvas, \"PREVIEW\" ) );\n\t\tvar previewNote = WeaponAnimatorTheme.Label(\n\t\t\t\"Viewmodel camera is optional. It previews a fixed origin and does not author the player's camera.\",\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\tpreviewNote.WordWrap = true;\n\t\tcanvas.Layout.Add( previewNote );\n\t\tvar modeRow = RigAuditPanel.Row( canvas );\n\t\tmodeRow.Layout.Add( Toggle(\n\t\t\tmodeRow,\n\t\t\t\"Viewmodel camera\",\n\t\t\t() => _controller.Document.Workspace.FirstPersonPreview,\n\t\t\tvalue => _controller.Mutate( \"Preview mode\", d => d.Workspace.FirstPersonPreview = value ) ), 1 );\n\t\tmodeRow.Layout.Add( Toggle(\n\t\t\tmodeRow,\n\t\t\t\"Safe area\",\n\t\t\t() => _controller.Document.Calibration.ShowSafeArea,\n\t\t\tvalue => _controller.Mutate( \"Safe area\", d => d.Calibration.ShowSafeArea = value ) ), 1 );\n\t\tcanvas.Layout.Add( modeRow );\n\t\tcanvas.Layout.Add( ChoiceButton(\n\t\t\tcanvas,\n\t\t\t\"Aspect\",\n\t\t\t() => _controller.Document.Calibration.AspectGuide,\n\t\t\t[\"4:3\", \"16:9\", \"21:9\"],\n\t\t\tvalue => _controller.Mutate( \"Aspect guide\", d => d.Calibration.AspectGuide = value ) ) );\n\t\tcanvas.Layout.Add( ChoiceButton(\n\t\t\tcanvas,\n\t\t\t\"Up axis\",\n\t\t\t() => _controller.Document.Calibration.UpAxis.ToString(),\n\t\t\tEnum.GetNames<WeaponUpAxis>(),\n\t\t\tvalue => _controller.Mutate(\n\t\t\t\t\"Alignment up axis\",\n\t\t\t\td => d.Calibration.UpAxis = Enum.Parse<WeaponUpAxis>( value ) ) ) );\n\t\tcanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Rebuild preview host\",\n\t\t\t\"refresh\",\n\t\t\t() => RebuildPreviewRequested?.Invoke(),\n\t\t\tcanvas ) );\n\n\t\tcanvas.Layout.Add( Section( canvas, \"CALIBRATION GATE\" ) );\n\t\t_confirmationState = WeaponAnimatorTheme.Label( \"\", canvas, true );\n\t\t_confirmationState.WordWrap = true;\n\t\tcanvas.Layout.Add( _confirmationState );\n\t\tcanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\"Confirm rig and continue\",\n\t\t\t\"arrow_forward\",\n\t\t\t() => ConfirmRequested?.Invoke(),\n\t\t\tcanvas,\n\t\t\ttrue ) );\n\t\tcanvas.Layout.AddStretchCell();\n\n\t\t_controller.DocumentChanged += Refresh;\n\t\t_controller.SelectionChanged += Refresh;\n\t\t_controller.PoseChanged += RefreshTransformValues;\n\t\tRefresh();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t_controller.DocumentChanged -= Refresh;\n\t\t_controller.SelectionChanged -= Refresh;\n\t\t_controller.PoseChanged -= RefreshTransformValues;\n\t\tbase.OnDestroyed();\n\t}\n\n\tpublic void SetModelDimensions( Vector3 dimensions )\n\t{\n\t\t_modelDimensions = dimensions;\n\t\tRefresh();\n\t}\n\n\tpublic void SetAlignmentMessage( string message )\n\t{\n\t\t_alignmentResult.Text = message;\n\t}\n\n\tprivate void Refresh()\n\t{\n\t\tvar document = _controller.Document;\n\t\tif ( CalibrationSelection.TryGetAnchor( document.Workspace.SelectedControl, out var anchorKind ) )\n\t\t{\n\t\t\tvar anchor = document.Calibration.GetAnchor( anchorKind );\n\t\t\t_selectedBone.Text = anchor is null\n\t\t\t\t? \"Anchor not set\"\n\t\t\t\t: $\"{CalibrationSelection.DisplayName( anchorKind )} \u00b7 use Move or Rotate in the viewport\";\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_selectedBone.Text = string.IsNullOrWhiteSpace( document.Workspace.SelectedBone )\n\t\t\t\t? \"No control selected\"\n\t\t\t\t: $\"{document.Workspace.SelectedBone} bone\";\n\t\t}\n\n\t\tvar measurement = document.Calibration.Measurement;\n\t\tif ( !_knownDistance.IsFocused )\n\t\t{\n\t\t\t_knownDistance.Value = measurement.KnownDistance > 0\n\t\t\t\t? measurement.KnownDistance.ToString( \"0.###\", CultureInfo.InvariantCulture )\n\t\t\t\t: \"\";\n\t\t}\n\t\tPreviewScale();\n\n\t\tvar report = WeaponAnimationValidator.ValidateCalibration( document );\n\t\t_confirmationState.Text = report.IsValid\n\t\t\t? \"All calibration checks pass. Confirmation will snapshot the rig and seed Idle.\"\n\t\t\t: string.Join( \"\\n\", report.Issues.Where( x => x.Blocking ).Take( 5 ).Select( x => $\"\u2022 {x.Message}\" ) );\n\t\tRefreshDocumentValues();\n\t}\n\n\tprivate void PreviewScale()\n\t{\n\t\tif ( !TryCalculateScalePreview( out _, out var preview ) )\n\t\t{\n\t\t\t_measurementResult.Text =\n\t\t\t\t\"Pick two distinct points and enter a positive known distance.\";\n\t\t\treturn;\n\t\t}\n\n\t\t_measurementResult.Text =\n\t\t\t$\"Measured {preview.MeasuredUnits:0.###} units. Scale \u00d7{preview.UniformScale:0.####}\\n\" +\n\t\t\t$\"Original XYZ: {FormatDimensions( preview.OriginalDimensions )}\\n\" +\n\t\t\t$\"Result XYZ: {FormatDimensions( preview.ResultingDimensions )}\";\n\t}\n\n\tprivate void CommitScalePreview()\n\t{\n\t\tvar hasPreview = TryCalculateScalePreview( out var known, out var preview );\n\t\tif ( !float.TryParse(\n\t\t\t_knownDistance.Text,\n\t\t\tNumberStyles.Float,\n\t\t\tCultureInfo.InvariantCulture,\n\t\t\tout known )\n\t\t\t|| !WeaponAnimationMath.IsFinite( known )\n\t\t\t|| known <= 0 )\n\t\t{\n\t\t\t_measurementResult.Text = \"Pick two distinct points and enter a positive known distance.\";\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.Mutate( \"Update scale measurement\", document =>\n\t\t{\n\t\t\tvar measurement = document.Calibration.Measurement;\n\t\t\tmeasurement.KnownDistance = known;\n\t\t\tmeasurement.HasPendingScale = hasPreview;\n\t\t\tif ( !hasPreview )\n\t\t\t\treturn;\n\t\t\tmeasurement.PreviewScale = preview.UniformScale;\n\t\t\tmeasurement.OriginalDimensions = preview.OriginalDimensions;\n\t\t\tmeasurement.ResultingDimensions = preview.ResultingDimensions;\n\t\t} );\n\t}\n\n\tprivate void ApplyScale()\n\t{\n\t\tif ( !TryCalculateScalePreview( out var known, out var preview ) )\n\t\t\treturn;\n\n\t\t_controller.Mutate( \"Apply uniform scale\", document =>\n\t\t{\n\t\t\tvar measurement = document.Calibration.Measurement;\n\t\t\tmeasurement.KnownDistance = known;\n\t\t\tmeasurement.PreviewScale = preview.UniformScale;\n\t\t\tmeasurement.OriginalDimensions = preview.OriginalDimensions;\n\t\t\tmeasurement.ResultingDimensions = preview.ResultingDimensions;\n\t\t\tdocument.Calibration.UniformScale = preview.UniformScale;\n\t\t\tdocument.Calibration.PhysicalTransform =\n\t\t\t\tdocument.Calibration.PhysicalTransform.WithScale( preview.UniformScale );\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t\tmeasurement.HasPendingScale = false;\n\t\t} );\n\t}\n\n\tprivate bool TryCalculateScalePreview(\n\t\tout float known,\n\t\tout ScalePreview preview )\n\t{\n\t\tpreview = default;\n\t\tif ( !float.TryParse(\n\t\t\t\t_knownDistance.Text,\n\t\t\t\tNumberStyles.Float,\n\t\t\t\tCultureInfo.InvariantCulture,\n\t\t\t\tout known )\n\t\t\t|| !WeaponAnimationMath.IsFinite( known )\n\t\t\t|| known <= 0 )\n\t\t\treturn false;\n\n\t\tvar measurement = _controller.Document.Calibration.Measurement;\n\t\treturn measurement.HasFirstPoint\n\t\t\t&& measurement.HasSecondPoint\n\t\t\t&& WeaponAnimationMath.TryCalculateUniformScale(\n\t\t\t\tmeasurement.FirstPoint,\n\t\t\t\tmeasurement.SecondPoint,\n\t\t\t\tknown,\n\t\t\t\tmeasurement.Unit,\n\t\t\t\t_modelDimensions,\n\t\t\t\tout preview );\n\t}\n\n\tprivate void RefreshTransformValues()\n\t{\n\t\tforeach ( var refresh in _transformRefreshers )\n\t\t\trefresh();\n\t}\n\n\tprivate void RefreshDocumentValues()\n\t{\n\t\tRefreshTransformValues();\n\t\tforeach ( var refresh in _documentRefreshers )\n\t\t\trefresh();\n\t}\n\n\tprivate void AddTransformFields( Widget parent, string label, bool framing )\n\t{\n\t\tparent.Layout.Add( WeaponAnimatorTheme.Label( label, parent, true ) );\n\t\tAddVectorField(\n\t\t\tparent,\n\t\t\t\"Position\",\n\t\t\t() => GetCalibrationTransform( framing ).Position,\n\t\t\t0.05f,\n\t\t\t(document, value) =>\n\t\t\t\tSetCalibrationTransform(\n\t\t\t\t\tdocument,\n\t\t\t\t\tframing,\n\t\t\t\t\tGetCalibrationTransform( document, framing ).WithPosition( value ) ) );\n\t\tAddVectorField(\n\t\t\tparent,\n\t\t\t\"Rotation\",\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tvar angles = GetCalibrationTransform( framing ).Rotation.Angles();\n\t\t\t\treturn new Vector3( angles.pitch, angles.yaw, angles.roll );\n\t\t\t},\n\t\t\t0.5f,\n\t\t\t(document, value) =>\n\t\t\t\tSetCalibrationTransform(\n\t\t\t\t\tdocument,\n\t\t\t\t\tframing,\n\t\t\t\t\tGetCalibrationTransform( document, framing )\n\t\t\t\t\t\t.WithRotation( Rotation.From( value.x, value.y, value.z ) ) ) );\n\t\tAddScalarField(\n\t\t\tparent,\n\t\t\t\"Scale\",\n\t\t\t() => GetCalibrationTransform( framing ).Scale.x,\n\t\t\t0.005f,\n\t\t\t(document, value) =>\n\t\t\t{\n\t\t\t\tvar clamped = MathF.Max( value, 0.0001f );\n\t\t\t\tSetCalibrationTransform(\n\t\t\t\t\tdocument,\n\t\t\t\t\tframing,\n\t\t\t\t\tGetCalibrationTransform( document, framing ).WithScale( clamped ) );\n\t\t\t\tif ( !framing )\n\t\t\t\t\tdocument.Calibration.UniformScale = clamped;\n\t\t\t} );\n\t}\n\n\tprivate void AddVectorField(\n\t\tWidget parent,\n\t\tstring label,\n\t\tFunc<Vector3> getter,\n\t\tfloat sensitivity,\n\t\tAction<WeaponAnimationDocument, Vector3> apply )\n\t{\n\t\tvar row = RigAuditPanel.Row( parent );\n\t\trow.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );\n\t\tvar edits = new LineEdit[3];\n\t\tvar axisNames = new[] { \"X\", \"Y\", \"Z\" };\n\t\tvar axisColors = new[]\n\t\t{\n\t\t\tWeaponAnimatorTheme.Coral,\n\t\t\tWeaponAnimatorTheme.Green,\n\t\t\tnew Color( 0.30f, 0.56f, 0.96f )\n\t\t};\n\t\tfor ( var index = 0; index < edits.Length; index++ )\n\t\t{\n\t\t\tvar capturedIndex = index;\n\t\t\tvar field = new Widget( row )\n\t\t\t{\n\t\t\t\tFixedWidth = 68,\n\t\t\t\tFixedHeight = 26,\n\t\t\t\tLayout = Layout.Row()\n\t\t\t};\n\t\t\tfield.Layout.Margin = 0;\n\t\t\tfield.Layout.Spacing = 0;\n\t\t\tvar edit = new LineEdit( field ) { FixedWidth = 48, FixedHeight = 26 };\n\t\t\tedit.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t\tedit.EditingFinished += () =>\n\t\t\t{\n\t\t\t\tvar current = getter();\n\t\t\t\tif ( !float.TryParse(\n\t\t\t\t\tedit.Text,\n\t\t\t\t\tNumberStyles.Float,\n\t\t\t\t\tCultureInfo.InvariantCulture,\n\t\t\t\t\tout var parsed ) )\n\t\t\t\t\treturn;\n\t\t\t\tcurrent[capturedIndex] = parsed;\n\t\t\t\t_controller.Mutate(\n\t\t\t\t\t$\"{label} {axisNames[capturedIndex]}\",\n\t\t\t\t\tdocument => apply( document, current ) );\n\t\t\t};\n\t\t\tfield.Layout.Add( new ScrubHandle(\n\t\t\t\taxisNames[capturedIndex],\n\t\t\t\taxisColors[capturedIndex],\n\t\t\t\tsensitivity,\n\t\t\t\t() => getter()[capturedIndex],\n\t\t\t\t() => _controller.BeginContinuousEdit( $\"{label} {axisNames[capturedIndex]}\" ),\n\t\t\t\tvalue =>\n\t\t\t\t{\n\t\t\t\t\tvar current = getter();\n\t\t\t\t\tcurrent[capturedIndex] = value;\n\t\t\t\t\t_controller.UpdateContinuousEdit( document => apply( document, current ) );\n\t\t\t\t},\n\t\t\t\t_controller.EndContinuousEdit,\n\t\t\t\tfield ) );\n\t\t\tfield.Layout.Add( edit );\n\t\t\tedits[index] = edit;\n\t\t\trow.Layout.Add( field );\n\t\t}\n\t\t_transformRefreshers.Add( () =>\n\t\t{\n\t\t\tvar value = getter();\n\t\t\tfor ( var index = 0; index < edits.Length; index++ )\n\t\t\t\tedits[index].Value = value[index].ToString( \"0.###\", CultureInfo.InvariantCulture );\n\t\t} );\n\t\tparent.Layout.Add( row );\n\t}\n\n\tprivate void AddScalarField(\n\t\tWidget parent,\n\t\tstring label,\n\t\tFunc<float> getter,\n\t\tfloat sensitivity,\n\t\tAction<WeaponAnimationDocument, float> apply )\n\t{\n\t\tvar row = RigAuditPanel.Row( parent );\n\t\trow.Layout.Add( WeaponAnimatorTheme.Label( label, row, true ), 1 );\n\t\tvar field = new Widget( row )\n\t\t{\n\t\t\tFixedWidth = 104,\n\t\t\tFixedHeight = 26,\n\t\t\tLayout = Layout.Row()\n\t\t};\n\t\tfield.Layout.Margin = 0;\n\t\tfield.Layout.Spacing = 0;\n\t\tvar edit = new LineEdit( field ) { FixedWidth = 72, FixedHeight = 26 };\n\t\tedit.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\tedit.EditingFinished += () =>\n\t\t{\n\t\t\tif ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )\n\t\t\t\t_controller.Mutate( label, document => apply( document, parsed ) );\n\t\t};\n\t\tfield.Layout.Add( new ScrubHandle(\n\t\t\t\"XYZ\",\n\t\t\tWeaponAnimatorTheme.Amber,\n\t\t\tsensitivity,\n\t\t\tgetter,\n\t\t\t() => _controller.BeginContinuousEdit( label ),\n\t\t\tvalue => _controller.UpdateContinuousEdit( document => apply( document, value ) ),\n\t\t\t_controller.EndContinuousEdit,\n\t\t\tfield )\n\t\t{\n\t\t\tFixedWidth = 32\n\t\t} );\n\t\tfield.Layout.Add( edit );\n\t\trow.Layout.Add( field );\n\t\t_transformRefreshers.Add( () =>\n\t\t\tedit.Value = getter().ToString( \"0.###\", CultureInfo.InvariantCulture ) );\n\t\tparent.Layout.Add( row );\n\t}\n\n\tprivate Transform GetCalibrationTransform( bool framing ) =>\n\t\tGetCalibrationTransform( _controller.Document, framing );\n\n\tprivate static Transform GetCalibrationTransform( WeaponAnimationDocument document, bool framing ) =>\n\t\tframing ? document.Calibration.FramingTransform : document.Calibration.PhysicalTransform;\n\n\tprivate static void SetCalibrationTransform(\n\t\tWeaponAnimationDocument document,\n\t\tbool framing,\n\t\tTransform value )\n\t{\n\t\tif ( framing )\n\t\t\tdocument.Calibration.FramingTransform = value;\n\t\telse\n\t\t{\n\t\t\tdocument.Calibration.PhysicalTransform = value;\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t}\n\t}\n\n\tprivate static string FormatDimensions( Vector3 dimensions )\n\t{\n\t\tvar centimetres = dimensions * WeaponAnimationMath.CentimetresPerInch;\n\t\treturn $\"{dimensions.x:0.##}\u00d7{dimensions.y:0.##}\u00d7{dimensions.z:0.##} in \u00b7 \" +\n\t\t\t$\"{centimetres.x:0.##}\u00d7{centimetres.y:0.##}\u00d7{centimetres.z:0.##} cm\";\n\t}\n\n\tprivate void ToggleUnit()\n\t{\n\t\t_controller.Mutate( \"Measurement unit\", document =>\n\t\t{\n\t\t\tvar measurement = document.Calibration.Measurement;\n\t\t\tif ( measurement.Unit == MeasurementUnit.Inches )\n\t\t\t{\n\t\t\t\tmeasurement.Unit = MeasurementUnit.Centimetres;\n\t\t\t\tmeasurement.KnownDistance *= WeaponAnimationMath.CentimetresPerInch;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tmeasurement.Unit = MeasurementUnit.Inches;\n\t\t\t\tmeasurement.KnownDistance /= WeaponAnimationMath.CentimetresPerInch;\n\t\t\t}\n\t\t} );\n\t}\n\n\tprivate void AddPickButton( Widget parent, string name, string icon, ViewportPickMode mode )\n\t{\n\t\tvar kind = mode switch\n\t\t{\n\t\t\tViewportPickMode.GripAnchor => AnchorKind.Grip,\n\t\t\tViewportPickMode.RearBoreAnchor => AnchorKind.RearBore,\n\t\t\tViewportPickMode.FrontBoreAnchor => AnchorKind.FrontBore,\n\t\t\tViewportPickMode.MuzzleAnchor => AnchorKind.Muzzle,\n\t\t\tViewportPickMode.EjectAnchor => AnchorKind.Eject,\n\t\t\t_ => AnchorKind.Custom\n\t\t};\n\t\tvar row = RigAuditPanel.Row( parent );\n\t\tvar select = WeaponAnimatorTheme.Button(\n\t\t\tname,\n\t\t\ticon,\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tif ( _controller.Document.Calibration.GetAnchor( kind ) is null )\n\t\t\t\t\tPickRequested?.Invoke( mode, Guid.Empty );\n\t\t\t\telse\n\t\t\t\t\t_controller.SelectControl( CalibrationSelection.Anchor( kind ) );\n\t\t\t},\n\t\t\trow );\n\t\trow.Layout.Add( select, 1 );\n\t\tvar repick = WeaponAnimatorTheme.Button(\n\t\t\t\"\",\n\t\t\t\"my_location\",\n\t\t\t() => PickRequested?.Invoke( mode, Guid.Empty ),\n\t\t\trow );\n\t\trepick.FixedWidth = 34;\n\t\trepick.ToolTip = $\"Pick {name.ToLowerInvariant()} again\";\n\t\trow.Layout.Add( repick );\n\t\tvar delete = WeaponAnimatorTheme.Button(\n\t\t\t\"\",\n\t\t\t\"delete\",\n\t\t\t() => DeleteAnchor( kind ),\n\t\t\trow );\n\t\tdelete.FixedWidth = 34;\n\t\tdelete.ToolTip = $\"Delete {name.ToLowerInvariant()}\";\n\t\trow.Layout.Add( delete );\n\t\t_documentRefreshers.Add( () =>\n\t\t{\n\t\t\tvar exists = _controller.Document.Calibration.GetAnchor( kind ) is not null;\n\t\t\tvar selected = _controller.Document.Workspace.SelectedControl == CalibrationSelection.Anchor( kind );\n\t\t\tselect.Text = exists ? $\"Move {name}\" : $\"Set {name}\";\n\t\t\tselect.Tint = selected\n\t\t\t\t? WeaponAnimatorTheme.Cyan * 0.48f\n\t\t\t\t: WeaponAnimatorTheme.SurfaceRaised;\n\t\t\trepick.Enabled = exists;\n\t\t\tdelete.Enabled = exists;\n\t\t} );\n\t\tparent.Layout.Add( row );\n\t}\n\n\tprivate void DeleteAnchor( AnchorKind kind )\n\t{\n\t\tif ( _controller.Document.Calibration.GetAnchor( kind ) is null )\n\t\t\treturn;\n\n\t\t_controller.Mutate( $\"Delete {CalibrationSelection.DisplayName( kind )} anchor\", document =>\n\t\t{\n\t\t\tdocument.Calibration.Anchors.RemoveAll( anchor => anchor.Kind == kind );\n\t\t\tif ( document.Workspace.SelectedControl == CalibrationSelection.Anchor( kind ) )\n\t\t\t\tdocument.Workspace.SelectedControl = \"\";\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\t/// <summary>\n\t/// Rebuilds the custom anchor rows only when the set actually changes, so selecting an anchor\n\t/// re-tints in place instead of tearing down live text fields the user may be editing.\n\t/// </summary>\n\tprivate void RefreshCustomAnchors()\n\t{\n\t\tif ( _customAnchorCanvas is null )\n\t\t\treturn;\n\n\t\tvar anchors = _controller.Document.Calibration.CustomAnchors().ToList();\n\t\tvar signature = string.Join(\n\t\t\t'\\n',\n\t\t\tanchors.Select( anchor => $\"{anchor.Id:N}\\t{anchor.GeneratedAttachmentName}\" ) );\n\t\tif ( signature != _customAnchorSignature )\n\t\t{\n\t\t\t_customAnchorSignature = signature;\n\t\t\t_customAnchorCanvas.Layout.Clear( true );\n\t\t\t_customAnchorButtons.Clear();\n\t\t\tforeach ( var anchor in anchors )\n\t\t\t\tAddCustomAnchorRow( anchor );\n\t\t}\n\n\t\tvar selected = _controller.Document.Workspace.SelectedControl;\n\t\tforeach ( var pair in _customAnchorButtons )\n\t\t{\n\t\t\tvar anchor = _controller.Document.Calibration.FindAnchor( pair.Key );\n\t\t\tpair.Value.Tint = anchor is not null\n\t\t\t\t&& selected == CalibrationSelection.Anchor( anchor )\n\t\t\t\t\t? WeaponAnimatorTheme.Cyan * 0.48f\n\t\t\t\t\t: WeaponAnimatorTheme.SurfaceRaised;\n\t\t}\n\t}\n\n\tprivate void AddCustomAnchorRow( WeaponAnchor anchor )\n\t{\n\t\tvar id = anchor.Id;\n\t\tvar row = RigAuditPanel.Row( _customAnchorCanvas! );\n\n\t\tvar edit = new LineEdit( row )\n\t\t{\n\t\t\tText = WeaponAnimationNames.AttachmentName( anchor ),\n\t\t\tPlaceholderText = \"attachment_name\",\n\t\t\tFixedHeight = 27,\n\t\t\tToolTip = \"Attachment name written into the generated prefab and model\"\n\t\t};\n\t\tedit.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t// EditingFinished fires on focus loss; ReturnPressed covers Enter explicitly.\n\t\tedit.EditingFinished += () => RenameCustomAnchor( id, edit.Text );\n\t\tedit.ReturnPressed += () => RenameCustomAnchor( id, edit.Text );\n\t\trow.Layout.Add( edit, 1 );\n\n\t\tvar select = WeaponAnimatorTheme.Button(\n\t\t\t\"\",\n\t\t\t\"ads_click\",\n\t\t\t() =>\n\t\t\t{\n\t\t\t\tif ( _controller.Document.Calibration.FindAnchor( id ) is { } target )\n\t\t\t\t\t_controller.SelectControl( CalibrationSelection.Anchor( target ) );\n\t\t\t},\n\t\t\trow );\n\t\tselect.FixedWidth = 34;\n\t\tselect.ToolTip = \"Select this anchor and show its gizmo\";\n\t\t_customAnchorButtons[id] = select;\n\t\trow.Layout.Add( select );\n\n\t\tvar place = WeaponAnimatorTheme.Button(\n\t\t\t\"\",\n\t\t\t\"my_location\",\n\t\t\t() => PickRequested?.Invoke( ViewportPickMode.CustomAnchor, id ),\n\t\t\trow );\n\t\tplace.FixedWidth = 34;\n\t\tplace.ToolTip = \"Click the weapon surface to place this anchor\";\n\t\trow.Layout.Add( place );\n\n\t\tvar remove = WeaponAnimatorTheme.Button(\n\t\t\t\"\",\n\t\t\t\"delete\",\n\t\t\t() => DeleteCustomAnchor( id ),\n\t\t\trow );\n\t\tremove.FixedWidth = 34;\n\t\tremove.ToolTip = \"Delete this anchor\";\n\t\trow.Layout.Add( remove );\n\n\t\t_customAnchorCanvas!.Layout.Add( row );\n\t}\n\n\tprivate void AddCustomAnchor()\n\t{\n\t\tvar id = Guid.NewGuid();\n\t\t_controller.Mutate( \"Add custom anchor\", document =>\n\t\t{\n\t\t\tdocument.Calibration.Anchors.Add( new WeaponAnchor\n\t\t\t{\n\t\t\t\tId = id,\n\t\t\t\tKind = AnchorKind.Custom,\n\t\t\t\tName = \"attachment\",\n\t\t\t\tBoneName = document.Workspace.SelectedBone\n\t\t\t} );\n\t\t\tWeaponAnimationNames.RepairCustomAnchorNames( document );\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t\tif ( _controller.Document.Calibration.FindAnchor( id ) is { } added )\n\t\t\t_controller.SelectControl( CalibrationSelection.Anchor( added ) );\n\t}\n\n\t/// <summary>\n\t/// The field edits the attachment name directly, so what the user types is what generation\n\t/// emits. Repair runs here rather than at generation time so any collision suffix is visible\n\t/// immediately instead of appearing silently in the output.\n\t/// </summary>\n\tprivate void RenameCustomAnchor( Guid id, string value )\n\t{\n\t\t_controller.Mutate( \"Rename custom anchor\", document =>\n\t\t{\n\t\t\tif ( document.Calibration.FindAnchor( id ) is not { } anchor )\n\t\t\t\treturn;\n\t\t\tvar slug = WeaponAnimationDocument.Slugify( value );\n\t\t\tif ( string.IsNullOrWhiteSpace( slug ) )\n\t\t\t\tslug = \"anchor\";\n\t\t\tif ( anchor.GeneratedAttachmentName == slug && anchor.Name == slug )\n\t\t\t\treturn;\n\t\t\tanchor.GeneratedAttachmentName = slug;\n\t\t\tanchor.Name = slug;\n\t\t\tWeaponAnimationNames.RepairCustomAnchorNames( document );\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\tprivate void DeleteCustomAnchor( Guid id )\n\t{\n\t\t_controller.Mutate( \"Delete custom anchor\", document =>\n\t\t{\n\t\t\tif ( document.Calibration.FindAnchor( id ) is not { } anchor )\n\t\t\t\treturn;\n\t\t\tvar token = CalibrationSelection.Anchor( anchor );\n\t\t\tdocument.Calibration.Anchors.Remove( anchor );\n\t\t\tif ( document.Workspace.SelectedControl == token )\n\t\t\t\tdocument.Workspace.SelectedControl = \"\";\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\tprivate void ClearAllAnchors()\n\t{\n\t\tif ( _controller.Document.Calibration.Anchors.Count == 0 )\n\t\t\treturn;\n\n\t\t_controller.Mutate( \"Clear all anchors\", document =>\n\t\t{\n\t\t\tdocument.Calibration.Anchors.Clear();\n\t\t\tif ( CalibrationSelection.TryGetAnchor( document.Workspace.SelectedControl, out _ ) )\n\t\t\t\tdocument.Workspace.SelectedControl = \"\";\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\tprivate static Label Section( Widget parent, string text )\n\t{\n\t\treturn WeaponAnimatorTheme.SectionLabel( text, parent, topMargin: true );\n\t}\n\n\tprivate Button Toggle(\n\t\tWidget parent,\n\t\tstring text,\n\t\tFunc<bool> current,\n\t\tAction<bool> changed )\n\t{\n\t\tvar button = new WeaponAnimatorButton( text, parent )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tIsChecked = current(),\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Toggled = () => changed( button.IsChecked );\n\t\t_documentRefreshers.Add( () => button.IsChecked = current() );\n\t\treturn button;\n\t}\n\n\tprivate Button ChoiceButton(\n\t\tWidget parent,\n\t\tstring label,\n\t\tFunc<string> current,\n\t\tSystem.Collections.Generic.IEnumerable<string> values,\n\t\tAction<string> changed )\n\t{\n\t\tvar button = new WeaponAnimatorButton( $\"{label}: {current()}\", \"expand_more\", parent )\n\t\t{\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Clicked = () =>\n\t\t{\n\t\t\tvar menu = new Menu( button );\n\t\t\tforeach ( var value in values )\n\t\t\t{\n\t\t\t\tvar captured = value;\n\t\t\t\tmenu.AddOption( captured, null, () =>\n\t\t\t\t{\n\t\t\t\t\tchanged( captured );\n\t\t\t\t\tbutton.Text = $\"{label}: {current()}\";\n\t\t\t\t\tbutton.FitToContent();\n\t\t\t\t} );\n\t\t\t}\n\t\t\tmenu.OpenAt( button.ScreenRect.BottomLeft );\n\t\t};\n\t\t_documentRefreshers.Add( () =>\n\t\t{\n\t\t\tbutton.Text = $\"{label}: {current()}\";\n\t\t\tbutton.FitToContent();\n\t\t} );\n\t\treturn button;\n\t}\n}\n\npublic sealed class ValidationStatusPanel : Widget\n{\n\tprivate readonly Label _label;\n\n\tpublic ValidationStatusPanel( Widget? parent = null ) : base( parent )\n\t{\n\t\tLayout = Layout.Row();\n\t\tLayout.Margin = new Sandbox.UI.Margin( 12, 8, 12, 8 );\n\t\t_label = WeaponAnimatorTheme.Label( \"Ready\", this, true );\n\t\t_label.WordWrap = true;\n\t\tLayout.Add( _label, 1 );\n\t}\n\n\tpublic void SetReport( ValidationReport report, string prefix = \"\" )\n\t{\n\t\tvar status = report.IsValid\n\t\t\t? report.WarningCount == 0 ? \"READY\" : $\"{report.WarningCount} WARNING(S)\"\n\t\t\t: $\"{report.ErrorCount} ERROR(S)\";\n\t\t_label.Color = report.IsValid\n\t\t\t? report.WarningCount == 0 ? WeaponAnimatorTheme.Green : WeaponAnimatorTheme.Amber\n\t\t\t: WeaponAnimatorTheme.Coral;\n\t\t_label.Text = string.IsNullOrWhiteSpace( prefix )\n\t\t\t? $\"{status} \u00b7 {string.Join( \"  \u00b7  \", report.Issues.Take( 4 ).Select( x => x.Message ) )}\"\n\t\t\t: $\"{prefix} \u00b7 {status} \u00b7 {string.Join( \"  \u00b7  \", report.Issues.Take( 4 ).Select( x => x.Message ) )}\";\n\t}\n\n\tpublic void SetMessage( string message, ValidationSeverity severity = ValidationSeverity.Info )\n\t{\n\t\t_label.Text = message;\n\t\t_label.Color = severity switch\n\t\t{\n\t\t\tValidationSeverity.Error => WeaponAnimatorTheme.Coral,\n\t\t\tValidationSeverity.Warning => WeaponAnimatorTheme.Amber,\n\t\t\t_ => WeaponAnimatorTheme.Muted\n\t\t};\n\t}\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Runtime/WeaponAnimationMath.cs",
            "FileName": "WeaponAnimationMath.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator;\n\npublic readonly record struct ScalePreview(\n\tfloat MeasuredUnits,\n\tfloat KnownInches,\n\tfloat UniformScale,\n\tVector3 OriginalDimensions,\n\tVector3 ResultingDimensions );\n\npublic readonly record struct AlignmentResult(\n\tTransform PhysicalTransform,\n\tbool BoreMayBeReversed,\n\tVector3 BoreDirection );\n\npublic readonly record struct TwoBoneSolution(\n\tVector3 Root,\n\tVector3 Elbow,\n\tVector3 End,\n\tbool Reachable,\n\tfloat RequestedDistance,\n\tfloat SolvedDistance );\n\npublic static class WeaponAnimationMath\n{\n\tpublic const float CentimetresPerInch = 2.54f;\n\tpublic const int MotionRateIntegrationSteps = 64;\n\tprivate const float Epsilon = 0.0001f;\n\n\tpublic static bool IsFinite( float value ) =>\n\t\t!float.IsNaN( value ) && !float.IsInfinity( value );\n\n\tpublic static bool IsFinite( Vector3 value ) =>\n\t\tIsFinite( value.x ) && IsFinite( value.y ) && IsFinite( value.z );\n\n\tpublic static bool TryCalculateUniformScale(\n\t\tVector3 firstPoint,\n\t\tVector3 secondPoint,\n\t\tfloat knownDistance,\n\t\tMeasurementUnit unit,\n\t\tVector3 originalDimensions,\n\t\tout ScalePreview preview )\n\t{\n\t\tpreview = default;\n\t\tvar measuredUnits = firstPoint.Distance( secondPoint );\n\t\tvar knownInches = unit == MeasurementUnit.Centimetres\n\t\t\t? knownDistance / CentimetresPerInch\n\t\t\t: knownDistance;\n\n\t\tif ( measuredUnits <= Epsilon || knownInches <= Epsilon )\n\t\t\treturn false;\n\n\t\tvar scale = knownInches / measuredUnits;\n\t\tif ( !IsFinite( scale ) || scale <= Epsilon )\n\t\t\treturn false;\n\n\t\tpreview = new ScalePreview(\n\t\t\tmeasuredUnits,\n\t\t\tknownInches,\n\t\t\tscale,\n\t\t\toriginalDimensions,\n\t\t\toriginalDimensions * scale );\n\n\t\treturn true;\n\t}\n\n\tpublic static bool TryCalculateAlignment(\n\t\tVector3 grip,\n\t\tVector3 rearBore,\n\t\tVector3 frontBore,\n\t\tWeaponUpAxis upAxis,\n\t\tfloat uniformScale,\n\t\tVector3 canonicalGrip,\n\t\tout AlignmentResult result )\n\t{\n\t\tresult = default;\n\n\t\tif ( !IsFinite( uniformScale ) || uniformScale <= Epsilon )\n\t\t\treturn false;\n\n\t\tvar scaledGrip = grip * uniformScale;\n\t\tvar bore = (frontBore - rearBore) * uniformScale;\n\t\tif ( bore.Length <= Epsilon )\n\t\t\treturn false;\n\n\t\tvar forward = bore.Normal;\n\t\tvar chosenUp = AxisVector( upAxis );\n\t\tvar projectedUp = (chosenUp - forward * Vector3.Dot( chosenUp, forward )).Normal;\n\t\tif ( projectedUp.Length <= Epsilon )\n\t\t\tprojectedUp = MathF.Abs( Vector3.Dot( forward, Vector3.Up ) ) < 0.95f\n\t\t\t\t? Vector3.Up\n\t\t\t\t: Vector3.Left;\n\n\t\tvar sourceBasis = Rotation.LookAt( forward, projectedUp );\n\t\tvar rotation = sourceBasis.Inverse;\n\t\tvar rotatedGrip = rotation * scaledGrip;\n\t\tvar position = canonicalGrip - rotatedGrip;\n\t\tvar physical = new Transform( position, rotation, uniformScale );\n\t\tvar reversed = Vector3.Dot( forward, Vector3.Forward ) < -0.25f;\n\n\t\tresult = new AlignmentResult( physical, reversed, forward );\n\t\treturn true;\n\t}\n\n\tpublic static Transform SampleTrack( TransformTrack track, float time, Transform fallback )\n\t{\n\t\tif ( track.Keys.Count == 0 || track.Muted )\n\t\t\treturn fallback;\n\n\t\tvar keys = track.Keys;\n\t\tif ( time <= keys[0].Time )\n\t\t\treturn KeyTransform( keys[0] );\n\t\tif ( time >= keys[^1].Time )\n\t\t\treturn KeyTransform( keys[^1] );\n\n\t\tvar low = 0;\n\t\tvar high = keys.Count - 1;\n\t\twhile ( low < high )\n\t\t{\n\t\t\tvar middle = low + (high - low) / 2;\n\t\t\tif ( keys[middle].Time < time )\n\t\t\t\tlow = middle + 1;\n\t\t\telse\n\t\t\t\thigh = middle;\n\t\t}\n\n\t\tif ( MathF.Abs( keys[low].Time - time ) <= Epsilon )\n\t\t\treturn KeyTransform( keys[low] );\n\t\treturn SampleSpan( track, keys[low - 1], keys[low], time );\n\t}\n\n\tprivate static Transform SampleSpan(\n\t\tTransformTrack track,\n\t\tTransformKey current,\n\t\tTransformKey next,\n\t\tfloat time )\n\t{\n\t\tvar duration = MathF.Max( next.Time - current.Time, Epsilon );\n\t\tvar fraction = Math.Clamp( (time - current.Time) / duration, 0.0f, 1.0f );\n\t\tvar span = track.FindCurveSpan( current.Id, next.Id );\n\t\tvar interpolation = span?.HasInterpolationOverride == true\n\t\t\t? span.Interpolation\n\t\t\t: track.Interpolation;\n\t\tvar hasSpeedCurve = span?.HasSpeedCurve == true;\n\t\tif ( interpolation == TrackInterpolation.Stepped && !hasSpeedCurve )\n\t\t\treturn KeyTransform( current );\n\n\t\tvar progress = hasSpeedCurve\n\t\t\t? SampleMotionProgress( span!.Speed, fraction )\n\t\t\t: fraction;\n\t\tvar valueInterpolation = hasSpeedCurve\n\t\t\t? TrackInterpolation.Linear\n\t\t\t: interpolation;\n\t\tif ( span is null || span.CustomChannels == TransformCurveChannel.None )\n\t\t{\n\t\t\tif ( valueInterpolation == TrackInterpolation.Cubic )\n\t\t\t\tprogress = SmoothStep( progress );\n\n\t\t\treturn new Transform(\n\t\t\t\tVector3.Lerp( current.Position, next.Position, progress ),\n\t\t\t\tRotation.Slerp( current.Rotation, next.Rotation, progress ),\n\t\t\t\tVector3.Lerp( current.Scale, next.Scale, progress ) );\n\t\t}\n\n\t\treturn new Transform(\n\t\t\tSampleVectorChannels(\n\t\t\t\tcurrent.Position,\n\t\t\t\tnext.Position,\n\t\t\t\tcurrent.CurveTangents.PositionOut,\n\t\t\t\tnext.CurveTangents.PositionIn,\n\t\t\t\tspan.CustomChannels,\n\t\t\t\tTransformCurveChannel.PositionX,\n\t\t\t\tprogress,\n\t\t\t\tduration,\n\t\t\t\tvalueInterpolation ),\n\t\t\tSampleRotationChannels(\n\t\t\t\tcurrent,\n\t\t\t\tnext,\n\t\t\t\tspan,\n\t\t\t\tprogress,\n\t\t\t\tduration,\n\t\t\t\tvalueInterpolation ),\n\t\t\tSampleVectorChannels(\n\t\t\t\tcurrent.Scale,\n\t\t\t\tnext.Scale,\n\t\t\t\tcurrent.CurveTangents.ScaleOut,\n\t\t\t\tnext.CurveTangents.ScaleIn,\n\t\t\t\tspan.CustomChannels,\n\t\t\t\tTransformCurveChannel.ScaleX,\n\t\t\t\tprogress,\n\t\t\t\tduration,\n\t\t\t\tvalueInterpolation ) );\n\t}\n\n\tpublic static float SampleMotionRate( MotionRateCurve curve, float fraction )\n\t{\n\t\tfraction = Math.Clamp( fraction, 0.0f, 1.0f );\n\t\tvar rate = Hermite(\n\t\t\tcurve.StartRate,\n\t\t\tcurve.EndRate,\n\t\t\tcurve.StartSlope,\n\t\t\tcurve.EndSlope,\n\t\t\tfraction );\n\t\treturn IsFinite( rate ) ? MathF.Max( rate, 0 ) : 0;\n\t}\n\n\tpublic static float SampleMotionProgress( MotionRateCurve curve, float fraction )\n\t{\n\t\tfraction = Math.Clamp( fraction, 0.0f, 1.0f );\n\t\tif ( fraction <= 0 )\n\t\t\treturn 0;\n\t\tif ( fraction >= 1 )\n\t\t\treturn 1;\n\n\t\tvar total = IntegrateMotionRate( curve, 1.0f );\n\t\tif ( total <= Epsilon || !IsFinite( total ) )\n\t\t\treturn fraction;\n\n\t\treturn Math.Clamp( IntegrateMotionRate( curve, fraction ) / total, 0.0f, 1.0f );\n\t}\n\n\tpublic static float MotionRateArea( MotionRateCurve curve ) =>\n\t\tIntegrateMotionRate( curve, 1.0f );\n\n\tpublic static float SnapTime( float time, float sampleRate, bool allowSubframes )\n\t{\n\t\tif ( allowSubframes || sampleRate <= Epsilon )\n\t\t\treturn MathF.Max( time, 0 );\n\n\t\treturn MathF.Max( MathF.Round( time * sampleRate ) / sampleRate, 0 );\n\t}\n\n\tpublic static TransformKey UpsertKey( TransformTrack track, float time, Transform value, float tolerance = 0.0001f )\n\t{\n\t\tvar existing = track.Keys.FirstOrDefault( x => MathF.Abs( x.Time - time ) <= tolerance );\n\t\tif ( existing is null )\n\t\t{\n\t\t\texisting = new TransformKey { Time = time };\n\t\t\ttrack.Keys.Add( existing );\n\t\t}\n\n\t\texisting.Position = value.Position;\n\t\texisting.Rotation = value.Rotation.Normal;\n\t\texisting.Scale = value.Scale;\n\t\ttrack.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );\n\t\treturn existing;\n\t}\n\n\tpublic static void RepairCurveSpans( TransformTrack track )\n\t{\n\t\tvar ordered = track.Keys.OrderBy( x => x.Time ).ToArray();\n\t\tvar adjacent = ordered\n\t\t\t.Zip( ordered.Skip( 1 ), ( start, end ) => (start.Id, end.Id) )\n\t\t\t.ToHashSet();\n\t\ttrack.CurveSpans.RemoveAll( span =>\n\t\t\tspan.StartKeyId == Guid.Empty\n\t\t\t|| span.EndKeyId == Guid.Empty\n\t\t\t|| !adjacent.Contains( (span.StartKeyId, span.EndKeyId) ) );\n\n\t\tforeach ( var duplicate in track.CurveSpans\n\t\t\t.GroupBy( x => (x.StartKeyId, x.EndKeyId) )\n\t\t\t.SelectMany( x => x.Skip( 1 ) )\n\t\t\t.ToArray() )\n\t\t{\n\t\t\ttrack.CurveSpans.Remove( duplicate );\n\t\t}\n\t}\n\n\tpublic static TwoBoneSolution SolveTwoBone(\n\t\tVector3 root,\n\t\tVector3 currentElbow,\n\t\tVector3 currentEnd,\n\t\tVector3 requestedTarget,\n\t\tVector3 pole )\n\t{\n\t\tvar upperLength = root.Distance( currentElbow );\n\t\tvar lowerLength = currentElbow.Distance( currentEnd );\n\t\tvar targetVector = requestedTarget - root;\n\t\tvar requestedDistance = targetVector.Length;\n\t\tvar direction = requestedDistance > Epsilon ? targetVector.Normal : Vector3.Forward;\n\t\tvar minimum = MathF.Abs( upperLength - lowerLength ) + Epsilon;\n\t\tvar maximum = MathF.Max( upperLength + lowerLength - Epsilon, minimum );\n\t\tvar solvedDistance = Math.Clamp( requestedDistance, minimum, maximum );\n\t\tvar reachable = requestedDistance >= minimum && requestedDistance <= maximum + Epsilon;\n\t\tvar solvedEnd = root + direction * solvedDistance;\n\n\t\tvar poleVector = pole - root;\n\t\tvar poleDirection = poleVector - direction * Vector3.Dot( poleVector, direction );\n\t\tif ( poleDirection.Length <= Epsilon )\n\t\t{\n\t\t\tvar fallback = MathF.Abs( Vector3.Dot( direction, Vector3.Up ) ) < 0.95f\n\t\t\t\t? Vector3.Up\n\t\t\t\t: Vector3.Left;\n\t\t\tpoleDirection = fallback - direction * Vector3.Dot( fallback, direction );\n\t\t}\n\n\t\tpoleDirection = poleDirection.Normal;\n\t\tvar along = (\n\t\t\tupperLength * upperLength\n\t\t\t- lowerLength * lowerLength\n\t\t\t+ solvedDistance * solvedDistance ) / (2.0f * solvedDistance);\n\t\tvar heightSquared = MathF.Max( upperLength * upperLength - along * along, 0 );\n\t\tvar elbow = root + direction * along + poleDirection * MathF.Sqrt( heightSquared );\n\t\treturn new TwoBoneSolution(\n\t\t\troot,\n\t\t\telbow,\n\t\t\tsolvedEnd,\n\t\t\treachable,\n\t\t\trequestedDistance,\n\t\t\tsolvedDistance );\n\t}\n\n\tpublic static Rotation RotationFromTo( Vector3 from, Vector3 to )\n\t{\n\t\tif ( from.Length <= Epsilon || to.Length <= Epsilon )\n\t\t\treturn Rotation.Identity;\n\n\t\tfrom = from.Normal;\n\t\tto = to.Normal;\n\t\tvar dot = Math.Clamp( Vector3.Dot( from, to ), -1.0f, 1.0f );\n\t\tvar axis = Vector3.Cross( from, to );\n\t\tif ( axis.Length <= Epsilon )\n\t\t{\n\t\t\tif ( dot >= 0 )\n\t\t\t\treturn Rotation.Identity;\n\n\t\t\tvar orthogonal = Vector3.Cross( from, Vector3.Up );\n\t\t\tif ( orthogonal.Length <= Epsilon )\n\t\t\t\torthogonal = Vector3.Cross( from, Vector3.Right );\n\t\t\treturn Rotation.FromAxis( orthogonal.Normal, 180.0f );\n\t\t}\n\n\t\treturn Rotation.FromAxis(\n\t\t\taxis.Normal,\n\t\t\tMathF.Acos( dot ).RadianToDegree() );\n\t}\n\n\tpublic static Transform Compose( Transform physical, Transform framing )\n\t{\n\t\tvar position = physical.PointToWorld( framing.Position );\n\t\tvar rotation = physical.Rotation * framing.Rotation;\n\t\tvar scale = physical.Scale * framing.Scale;\n\t\treturn new Transform( position, rotation, scale );\n\t}\n\n\tpublic static float ToCentimetres( float sboxUnits ) => sboxUnits * CentimetresPerInch;\n\n\tpublic static Vector3 AxisVector( WeaponUpAxis axis ) => axis switch\n\t{\n\t\tWeaponUpAxis.NegativeZ => Vector3.Down,\n\t\tWeaponUpAxis.PositiveY => Vector3.Left,\n\t\tWeaponUpAxis.NegativeY => Vector3.Right,\n\t\t_ => Vector3.Up\n\t};\n\n\tprivate static Transform KeyTransform( TransformKey key ) =>\n\t\tnew( key.Position, key.Rotation.Normal, key.Scale );\n\n\tprivate static float IntegrateMotionRate( MotionRateCurve curve, float end )\n\t{\n\t\tend = Math.Clamp( end, 0.0f, 1.0f );\n\t\tif ( end <= 0 )\n\t\t\treturn 0;\n\n\t\tvar step = 1.0f / MotionRateIntegrationSteps;\n\t\tvar wholeSteps = Math.Clamp(\n\t\t\t(int)MathF.Floor( end * MotionRateIntegrationSteps ),\n\t\t\t0,\n\t\t\tMotionRateIntegrationSteps );\n\t\tvar area = 0.0f;\n\t\tfor ( var index = 0; index < wholeSteps; index++ )\n\t\t{\n\t\t\tvar start = index * step;\n\t\t\tvar finish = (index + 1) * step;\n\t\t\tarea += (SampleMotionRate( curve, start ) + SampleMotionRate( curve, finish ))\n\t\t\t\t* 0.5f * step;\n\t\t}\n\n\t\tvar remainderStart = wholeSteps * step;\n\t\tif ( remainderStart < end )\n\t\t{\n\t\t\tarea += (SampleMotionRate( curve, remainderStart ) + SampleMotionRate( curve, end ))\n\t\t\t\t* 0.5f * (end - remainderStart);\n\t\t}\n\t\treturn area;\n\t}\n\n\tprivate static Vector3 SampleVectorChannels(\n\t\tVector3 start,\n\t\tVector3 end,\n\t\tVector3 startTangents,\n\t\tVector3 endTangents,\n\t\tTransformCurveChannel customChannels,\n\t\tTransformCurveChannel firstChannel,\n\t\tfloat progress,\n\t\tfloat duration,\n\t\tTrackInterpolation interpolation )\n\t{\n\t\tvar legacy = interpolation == TrackInterpolation.Cubic\n\t\t\t? SmoothStep( progress )\n\t\t\t: progress;\n\t\treturn new Vector3(\n\t\t\tSampleScalarChannel(\n\t\t\t\tstart.x, end.x, startTangents.x, endTangents.x,\n\t\t\t\t(customChannels & firstChannel) != 0, progress, legacy, duration ),\n\t\t\tSampleScalarChannel(\n\t\t\t\tstart.y, end.y, startTangents.y, endTangents.y,\n\t\t\t\t(customChannels & (TransformCurveChannel)((int)firstChannel << 1)) != 0,\n\t\t\t\tprogress, legacy, duration ),\n\t\t\tSampleScalarChannel(\n\t\t\t\tstart.z, end.z, startTangents.z, endTangents.z,\n\t\t\t\t(customChannels & (TransformCurveChannel)((int)firstChannel << 2)) != 0,\n\t\t\t\tprogress, legacy, duration ) );\n\t}\n\n\tprivate static float SampleScalarChannel(\n\t\tfloat start,\n\t\tfloat end,\n\t\tfloat startTangent,\n\t\tfloat endTangent,\n\t\tbool custom,\n\t\tfloat progress,\n\t\tfloat legacyProgress,\n\t\tfloat duration ) =>\n\t\tcustom\n\t\t\t? Hermite( start, end, startTangent * duration, endTangent * duration, progress )\n\t\t\t: start.LerpTo( end, legacyProgress );\n\n\tprivate static Rotation SampleRotationChannels(\n\t\tTransformKey current,\n\t\tTransformKey next,\n\t\tTransformCurveSpan span,\n\t\tfloat progress,\n\t\tfloat duration,\n\t\tTrackInterpolation interpolation )\n\t{\n\t\tvar custom = span.CustomChannels & TransformCurveChannel.Rotation;\n\t\tvar legacy = interpolation == TrackInterpolation.Cubic\n\t\t\t? SmoothStep( progress )\n\t\t\t: progress;\n\t\tif ( custom == TransformCurveChannel.None )\n\t\t\treturn Rotation.Slerp( current.Rotation, next.Rotation, legacy );\n\n\t\tvar startAngles = current.Rotation.Angles();\n\t\tvar endAngles = next.Rotation.Angles();\n\t\tvar start = new Vector3( startAngles.pitch, startAngles.yaw, startAngles.roll );\n\t\tvar end = new Vector3(\n\t\t\tUnwrapDegrees( start.x, endAngles.pitch ),\n\t\t\tUnwrapDegrees( start.y, endAngles.yaw ),\n\t\t\tUnwrapDegrees( start.z, endAngles.roll ) );\n\t\tvar legacyRotation = Rotation.Slerp( current.Rotation, next.Rotation, legacy );\n\t\tvar legacyAngles = legacyRotation.Angles();\n\t\tvar legacyValues = new Vector3(\n\t\t\tUnwrapDegrees( start.x, legacyAngles.pitch ),\n\t\t\tUnwrapDegrees( start.y, legacyAngles.yaw ),\n\t\t\tUnwrapDegrees( start.z, legacyAngles.roll ) );\n\t\tvar sampled = new Vector3(\n\t\t\t(custom & TransformCurveChannel.RotationX) != 0\n\t\t\t\t? Hermite(\n\t\t\t\t\tstart.x,\n\t\t\t\t\tend.x,\n\t\t\t\t\tcurrent.CurveTangents.RotationOut.x * duration,\n\t\t\t\t\tnext.CurveTangents.RotationIn.x * duration,\n\t\t\t\t\tprogress )\n\t\t\t\t: legacyValues.x,\n\t\t\t(custom & TransformCurveChannel.RotationY) != 0\n\t\t\t\t? Hermite(\n\t\t\t\t\tstart.y,\n\t\t\t\t\tend.y,\n\t\t\t\t\tcurrent.CurveTangents.RotationOut.y * duration,\n\t\t\t\t\tnext.CurveTangents.RotationIn.y * duration,\n\t\t\t\t\tprogress )\n\t\t\t\t: legacyValues.y,\n\t\t\t(custom & TransformCurveChannel.RotationZ) != 0\n\t\t\t\t? Hermite(\n\t\t\t\t\tstart.z,\n\t\t\t\t\tend.z,\n\t\t\t\t\tcurrent.CurveTangents.RotationOut.z * duration,\n\t\t\t\t\tnext.CurveTangents.RotationIn.z * duration,\n\t\t\t\t\tprogress )\n\t\t\t\t: legacyValues.z );\n\t\treturn Rotation.From( new Angles( sampled.x, sampled.y, sampled.z ) ).Normal;\n\t}\n\n\tprivate static float Hermite(\n\t\tfloat start,\n\t\tfloat end,\n\t\tfloat startTangent,\n\t\tfloat endTangent,\n\t\tfloat amount )\n\t{\n\t\tvar amount2 = amount * amount;\n\t\tvar amount3 = amount2 * amount;\n\t\treturn (2 * amount3 - 3 * amount2 + 1) * start\n\t\t\t+ (amount3 - 2 * amount2 + amount) * startTangent\n\t\t\t+ (-2 * amount3 + 3 * amount2) * end\n\t\t\t+ (amount3 - amount2) * endTangent;\n\t}\n\n\tprivate static float SmoothStep( float amount ) =>\n\t\tamount * amount * (3.0f - 2.0f * amount);\n\n\tprivate static float UnwrapDegrees( float reference, float value )\n\t{\n\t\tvar difference = (value - reference) % 360.0f;\n\t\tif ( difference > 180 )\n\t\t\tdifference -= 360;\n\t\telse if ( difference < -180 )\n\t\t\tdifference += 360;\n\t\treturn reference + difference;\n\t}\n}\n\npublic static class ClipConstraintEvaluator\n{\n\tpublic static Transform Apply(\n\t\tTransform source,\n\t\tTransform target,\n\t\tTimedConstraint constraint,\n\t\tfloat time,\n\t\tTransform maintainedOffset )\n\t{\n\t\tif ( time < constraint.StartTime || time > constraint.EndTime || constraint.Weight <= 0 )\n\t\t\treturn source;\n\n\t\tvar desired = constraint.MaintainOffset\n\t\t\t? new Transform(\n\t\t\t\ttarget.PointToWorld( maintainedOffset.Position ),\n\t\t\t\ttarget.Rotation * maintainedOffset.Rotation,\n\t\t\t\ttarget.Scale * maintainedOffset.Scale )\n\t\t\t: target;\n\n\t\tvar weight = Math.Clamp( constraint.Weight, 0.0f, 1.0f );\n\t\treturn new Transform(\n\t\t\tVector3.Lerp( source.Position, desired.Position, weight ),\n\t\t\tRotation.Slerp( source.Rotation, desired.Rotation, weight ),\n\t\t\tVector3.Lerp( source.Scale, desired.Scale, weight ) );\n\t}\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Runtime/WeaponRigHierarchy.cs",
            "FileName": "WeaponRigHierarchy.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator;\n\npublic static class WeaponRigHierarchy\n{\n\tpublic static void RepairMetadata( WeaponRigDefinition rig, bool legacyBindTransforms )\n\t{\n\t\tvar byName = rig.Bones\n\t\t\t.GroupBy( x => x.Name, StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToDictionary( x => x.Key, x => x.First(), StringComparer.OrdinalIgnoreCase );\n\t\tvar paths = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );\n\n\t\tstring ResolvePath( WeaponBoneDefinition bone, HashSet<string> visiting )\n\t\t{\n\t\t\tif ( paths.TryGetValue( bone.Name, out var existing ) )\n\t\t\t\treturn existing;\n\t\t\tif ( !visiting.Add( bone.Name ) )\n\t\t\t\treturn EscapePathPart( bone.Name );\n\n\t\t\tvar own = EscapePathPart( bone.Name );\n\t\t\tif ( !string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t&& byName.TryGetValue( bone.ParentName, out var parent ) )\n\t\t\t{\n\t\t\t\town = $\"{ResolvePath( parent, visiting )}/{own}\";\n\t\t\t}\n\n\t\t\tvisiting.Remove( bone.Name );\n\t\t\tpaths[bone.Name] = own;\n\t\t\treturn own;\n\t\t}\n\n\t\tforeach ( var bone in rig.Bones )\n\t\t{\n\t\t\tbone.HierarchyPath = ResolvePath( bone, [] );\n\t\t\tbone.Id = bone.HierarchyPath;\n\t\t\tbone.OriginalName = string.IsNullOrWhiteSpace( bone.OriginalName )\n\t\t\t\t? bone.Name\n\t\t\t\t: bone.OriginalName;\n\t\t\tbone.OriginalParentName = string.IsNullOrWhiteSpace( bone.OriginalParentName )\n\t\t\t\t? bone.ParentName\n\t\t\t\t: bone.OriginalParentName;\n\n\t\t\tif ( legacyBindTransforms )\n\t\t\t\tbone.BindModelTransform = bone.BindTransform;\n\t\t\telse\n\t\t\t\tbone.BindTransform = bone.BindModelTransform;\n\t\t}\n\n\t\tforeach ( var bone in rig.Bones )\n\t\t{\n\t\t\tvar parent = string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t? null\n\t\t\t\t: rig.FindBone( bone.ParentName );\n\t\t\tbone.ParentId = parent?.Id ?? \"\";\n\t\t\tbone.BindLocalTransform = parent is null\n\t\t\t\t? bone.BindModelTransform\n\t\t\t\t: parent.BindModelTransform.ToLocal( bone.BindModelTransform );\n\t\t}\n\n\t\tvar sourceRoot = rig.Bones.FirstOrDefault( x => string.IsNullOrWhiteSpace( x.ParentId ) );\n\t\trig.SourceSkeletonRootId = sourceRoot?.Id ?? \"\";\n\t\tvar weaponRoot = rig.Bones.FirstOrDefault( x =>\n\t\t\tx.Classification == WeaponBoneClassification.WeaponRoot );\n\t\tif ( weaponRoot is not null )\n\t\t{\n\t\t\trig.RootBone = weaponRoot.Name;\n\t\t\trig.WeaponSubtreeRootId = weaponRoot.Id;\n\t\t}\n\t}\n\n\tpublic static bool SelectWeaponSubtree( WeaponRigDefinition rig, string idOrName )\n\t{\n\t\tvar selected = rig.FindBone( idOrName );\n\t\tif ( selected is null )\n\t\t\treturn false;\n\n\t\tvar descendants = DescendantIds( rig, selected.Id );\n\t\tvar ancestors = AncestorIds( rig, selected );\n\t\tforeach ( var bone in rig.Bones )\n\t\t{\n\t\t\tif ( bone.Id.Equals( selected.Id, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t{\n\t\t\t\tbone.Inclusion = WeaponBoneInclusion.Included;\n\t\t\t\tbone.Classification = WeaponBoneClassification.WeaponRoot;\n\t\t\t}\n\t\t\telse if ( descendants.Contains( bone.Id ) )\n\t\t\t{\n\t\t\t\tbone.Inclusion = WeaponBoneInclusion.Included;\n\t\t\t\tif ( bone.Classification is WeaponBoneClassification.Ignored\n\t\t\t\t\tor WeaponBoneClassification.WeaponRoot )\n\t\t\t\t\tbone.Classification = WeaponBoneClassification.Animatable;\n\t\t\t}\n\t\t\telse if ( ancestors.Contains( bone.Id ) )\n\t\t\t{\n\t\t\t\tbone.Inclusion = WeaponBoneInclusion.StructuralBridge;\n\t\t\t\tbone.Classification = WeaponBoneClassification.Structural;\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tbone.Inclusion = WeaponBoneInclusion.Excluded;\n\t\t\t\tbone.Classification = WeaponBoneClassification.Ignored;\n\t\t\t}\n\t\t}\n\n\t\trig.RootBone = selected.Name;\n\t\trig.WeaponSubtreeRootId = selected.Id;\n\t\tRequireReview( rig );\n\t\treturn true;\n\t}\n\n\tpublic static bool ExcludeBranch( WeaponRigDefinition rig, string idOrName )\n\t{\n\t\tvar selected = rig.FindBone( idOrName );\n\t\tif ( selected is null || string.IsNullOrWhiteSpace( rig.WeaponSubtreeRootId ) )\n\t\t\treturn false;\n\n\t\tvar protectedIds = AncestorIds(\n\t\t\trig,\n\t\t\trig.FindBone( rig.WeaponSubtreeRootId ) ?? selected );\n\t\tprotectedIds.Add( rig.WeaponSubtreeRootId );\n\t\tif ( protectedIds.Contains( selected.Id ) )\n\t\t\treturn false;\n\n\t\tvar branch = DescendantIds( rig, selected.Id );\n\t\tbranch.Add( selected.Id );\n\t\tforeach ( var bone in rig.Bones.Where( x => branch.Contains( x.Id ) ) )\n\t\t{\n\t\t\tbone.Inclusion = WeaponBoneInclusion.Excluded;\n\t\t\tbone.Classification = WeaponBoneClassification.Ignored;\n\t\t}\n\n\t\tRequireReview( rig );\n\t\treturn true;\n\t}\n\n\tpublic static bool IncludeBranch( WeaponRigDefinition rig, string idOrName )\n\t{\n\t\tvar selected = rig.FindBone( idOrName );\n\t\tvar root = rig.FindBone( rig.WeaponSubtreeRootId );\n\t\tif ( selected is null || root is null )\n\t\t\treturn false;\n\n\t\tvar rootBranch = DescendantIds( rig, root.Id );\n\t\trootBranch.Add( root.Id );\n\t\tif ( !rootBranch.Contains( selected.Id ) )\n\t\t\treturn false;\n\n\t\tvar branch = DescendantIds( rig, selected.Id );\n\t\tbranch.Add( selected.Id );\n\t\tforeach ( var bone in rig.Bones.Where( x => branch.Contains( x.Id ) ) )\n\t\t{\n\t\t\tbone.Inclusion = WeaponBoneInclusion.Included;\n\t\t\tbone.Classification = bone.Id.Equals( root.Id, StringComparison.OrdinalIgnoreCase )\n\t\t\t\t? WeaponBoneClassification.WeaponRoot\n\t\t\t\t: WeaponBoneClassification.Animatable;\n\t\t}\n\n\t\tRequireReview( rig );\n\t\treturn true;\n\t}\n\n\tpublic static void ConfirmFilteredPreview( WeaponRigDefinition rig )\n\t{\n\t\trig.ReviewRequired = false;\n\t\trig.FilteredPreviewConfirmed = true;\n\t}\n\n\tpublic static bool IsRetained( WeaponBoneDefinition bone ) =>\n\t\tbone.Inclusion != WeaponBoneInclusion.Excluded\n\t\t&& bone.Classification != WeaponBoneClassification.Ignored;\n\n\tpublic static string ProfileText( WeaponRigDefinition rig ) => string.Join(\n\t\t\"\\n\",\n\t\trig.Bones\n\t\t\t.OrderBy( x => x.Id, StringComparer.OrdinalIgnoreCase )\n\t\t\t.Select( x =>\n\t\t\t\t$\"{x.Id}|{x.ParentId}|{x.Name}|{x.Classification}|{x.Inclusion}|\"\n\t\t\t\t+ $\"{x.BindModelTransform}|{x.BindLocalTransform}\" ) );\n\n\tprivate static HashSet<string> DescendantIds( WeaponRigDefinition rig, string rootId )\n\t{\n\t\tvar result = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar pending = new Queue<string>();\n\t\tpending.Enqueue( rootId );\n\t\twhile ( pending.Count > 0 )\n\t\t{\n\t\t\tvar parentId = pending.Dequeue();\n\t\t\tforeach ( var child in rig.Bones.Where( x =>\n\t\t\t\tx.ParentId.Equals( parentId, StringComparison.OrdinalIgnoreCase ) ) )\n\t\t\t{\n\t\t\t\tif ( result.Add( child.Id ) )\n\t\t\t\t\tpending.Enqueue( child.Id );\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate static HashSet<string> AncestorIds(\n\t\tWeaponRigDefinition rig,\n\t\tWeaponBoneDefinition bone )\n\t{\n\t\tvar result = new HashSet<string>( StringComparer.OrdinalIgnoreCase );\n\t\tvar parentId = bone.ParentId;\n\t\twhile ( !string.IsNullOrWhiteSpace( parentId ) && result.Add( parentId ) )\n\t\t\tparentId = rig.FindBone( parentId )?.ParentId ?? \"\";\n\t\treturn result;\n\t}\n\n\tprivate static void RequireReview( WeaponRigDefinition rig )\n\t{\n\t\trig.ReviewRequired = true;\n\t\trig.FilteredPreviewConfirmed = false;\n\t}\n\n\tprivate static string EscapePathPart( string value ) =>\n\t\tvalue.Replace( \"%\", \"%25\", StringComparison.Ordinal )\n\t\t\t.Replace( \"/\", \"%2F\", StringComparison.Ordinal );\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": ".obj/__compiler_extra.cs",
            "FileName": "__compiler_extra.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337289,
            "Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"S&box Weapon Animator\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"sbox-animator\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"sonac\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"sonac.sbox-animator\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"28\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-07-29T19:18:21.4795109Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.115.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.115.0\")]"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Code/Runtime/WeaponAnimationMath.cs",
            "FileName": "WeaponAnimationMath.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator;\n\npublic readonly record struct ScalePreview(\n\tfloat MeasuredUnits,\n\tfloat KnownInches,\n\tfloat UniformScale,\n\tVector3 OriginalDimensions,\n\tVector3 ResultingDimensions );\n\npublic readonly record struct AlignmentResult(\n\tTransform PhysicalTransform,\n\tbool BoreMayBeReversed,\n\tVector3 BoreDirection );\n\npublic readonly record struct TwoBoneSolution(\n\tVector3 Root,\n\tVector3 Elbow,\n\tVector3 End,\n\tbool Reachable,\n\tfloat RequestedDistance,\n\tfloat SolvedDistance );\n\npublic static class WeaponAnimationMath\n{\n\tpublic const float CentimetresPerInch = 2.54f;\n\tpublic const int MotionRateIntegrationSteps = 64;\n\tprivate const float Epsilon = 0.0001f;\n\n\tpublic static bool IsFinite( float value ) =>\n\t\t!float.IsNaN( value ) && !float.IsInfinity( value );\n\n\tpublic static bool IsFinite( Vector3 value ) =>\n\t\tIsFinite( value.x ) && IsFinite( value.y ) && IsFinite( value.z );\n\n\tpublic static bool TryCalculateUniformScale(\n\t\tVector3 firstPoint,\n\t\tVector3 secondPoint,\n\t\tfloat knownDistance,\n\t\tMeasurementUnit unit,\n\t\tVector3 originalDimensions,\n\t\tout ScalePreview preview )\n\t{\n\t\tpreview = default;\n\t\tvar measuredUnits = firstPoint.Distance( secondPoint );\n\t\tvar knownInches = unit == MeasurementUnit.Centimetres\n\t\t\t? knownDistance / CentimetresPerInch\n\t\t\t: knownDistance;\n\n\t\tif ( measuredUnits <= Epsilon || knownInches <= Epsilon )\n\t\t\treturn false;\n\n\t\tvar scale = knownInches / measuredUnits;\n\t\tif ( !IsFinite( scale ) || scale <= Epsilon )\n\t\t\treturn false;\n\n\t\tpreview = new ScalePreview(\n\t\t\tmeasuredUnits,\n\t\t\tknownInches,\n\t\t\tscale,\n\t\t\toriginalDimensions,\n\t\t\toriginalDimensions * scale );\n\n\t\treturn true;\n\t}\n\n\tpublic static bool TryCalculateAlignment(\n\t\tVector3 grip,\n\t\tVector3 rearBore,\n\t\tVector3 frontBore,\n\t\tWeaponUpAxis upAxis,\n\t\tfloat uniformScale,\n\t\tVector3 canonicalGrip,\n\t\tout AlignmentResult result )\n\t{\n\t\tresult = default;\n\n\t\tif ( !IsFinite( uniformScale ) || uniformScale <= Epsilon )\n\t\t\treturn false;\n\n\t\tvar scaledGrip = grip * uniformScale;\n\t\tvar bore = (frontBore - rearBore) * uniformScale;\n\t\tif ( bore.Length <= Epsilon )\n\t\t\treturn false;\n\n\t\tvar forward = bore.Normal;\n\t\tvar chosenUp = AxisVector( upAxis );\n\t\tvar projectedUp = (chosenUp - forward * Vector3.Dot( chosenUp, forward )).Normal;\n\t\tif ( projectedUp.Length <= Epsilon )\n\t\t\tprojectedUp = MathF.Abs( Vector3.Dot( forward, Vector3.Up ) ) < 0.95f\n\t\t\t\t? Vector3.Up\n\t\t\t\t: Vector3.Left;\n\n\t\tvar sourceBasis = Rotation.LookAt( forward, projectedUp );\n\t\tvar rotation = sourceBasis.Inverse;\n\t\tvar rotatedGrip = rotation * scaledGrip;\n\t\tvar position = canonicalGrip - rotatedGrip;\n\t\tvar physical = new Transform( position, rotation, uniformScale );\n\t\tvar reversed = Vector3.Dot( forward, Vector3.Forward ) < -0.25f;\n\n\t\tresult = new AlignmentResult( physical, reversed, forward );\n\t\treturn true;\n\t}\n\n\tpublic static Transform SampleTrack( TransformTrack track, float time, Transform fallback )\n\t{\n\t\tif ( track.Keys.Count == 0 || track.Muted )\n\t\t\treturn fallback;\n\n\t\tvar keys = track.Keys;\n\t\tif ( time <= keys[0].Time )\n\t\t\treturn KeyTransform( keys[0] );\n\t\tif ( time >= keys[^1].Time )\n\t\t\treturn KeyTransform( keys[^1] );\n\n\t\tvar low = 0;\n\t\tvar high = keys.Count - 1;\n\t\twhile ( low < high )\n\t\t{\n\t\t\tvar middle = low + (high - low) / 2;\n\t\t\tif ( keys[middle].Time < time )\n\t\t\t\tlow = middle + 1;\n\t\t\telse\n\t\t\t\thigh = middle;\n\t\t}\n\n\t\tif ( MathF.Abs( keys[low].Time - time ) <= Epsilon )\n\t\t\treturn KeyTransform( keys[low] );\n\t\treturn SampleSpan( track, keys[low - 1], keys[low], time );\n\t}\n\n\tprivate static Transform SampleSpan(\n\t\tTransformTrack track,\n\t\tTransformKey current,\n\t\tTransformKey next,\n\t\tfloat time )\n\t{\n\t\tvar duration = MathF.Max( next.Time - current.Time, Epsilon );\n\t\tvar fraction = Math.Clamp( (time - current.Time) / duration, 0.0f, 1.0f );\n\t\tvar span = track.FindCurveSpan( current.Id, next.Id );\n\t\tvar interpolation = span?.HasInterpolationOverride == true\n\t\t\t? span.Interpolation\n\t\t\t: track.Interpolation;\n\t\tvar hasSpeedCurve = span?.HasSpeedCurve == true;\n\t\tif ( interpolation == TrackInterpolation.Stepped && !hasSpeedCurve )\n\t\t\treturn KeyTransform( current );\n\n\t\tvar progress = hasSpeedCurve\n\t\t\t? SampleMotionProgress( span!.Speed, fraction )\n\t\t\t: fraction;\n\t\tvar valueInterpolation = hasSpeedCurve\n\t\t\t? TrackInterpolation.Linear\n\t\t\t: interpolation;\n\t\tif ( span is null || span.CustomChannels == TransformCurveChannel.None )\n\t\t{\n\t\t\tif ( valueInterpolation == TrackInterpolation.Cubic )\n\t\t\t\tprogress = SmoothStep( progress );\n\n\t\t\treturn new Transform(\n\t\t\t\tVector3.Lerp( current.Position, next.Position, progress ),\n\t\t\t\tRotation.Slerp( current.Rotation, next.Rotation, progress ),\n\t\t\t\tVector3.Lerp( current.Scale, next.Scale, progress ) );\n\t\t}\n\n\t\treturn new Transform(\n\t\t\tSampleVectorChannels(\n\t\t\t\tcurrent.Position,\n\t\t\t\tnext.Position,\n\t\t\t\tcurrent.CurveTangents.PositionOut,\n\t\t\t\tnext.CurveTangents.PositionIn,\n\t\t\t\tspan.CustomChannels,\n\t\t\t\tTransformCurveChannel.PositionX,\n\t\t\t\tprogress,\n\t\t\t\tduration,\n\t\t\t\tvalueInterpolation ),\n\t\t\tSampleRotationChannels(\n\t\t\t\tcurrent,\n\t\t\t\tnext,\n\t\t\t\tspan,\n\t\t\t\tprogress,\n\t\t\t\tduration,\n\t\t\t\tvalueInterpolation ),\n\t\t\tSampleVectorChannels(\n\t\t\t\tcurrent.Scale,\n\t\t\t\tnext.Scale,\n\t\t\t\tcurrent.CurveTangents.ScaleOut,\n\t\t\t\tnext.CurveTangents.ScaleIn,\n\t\t\t\tspan.CustomChannels,\n\t\t\t\tTransformCurveChannel.ScaleX,\n\t\t\t\tprogress,\n\t\t\t\tduration,\n\t\t\t\tvalueInterpolation ) );\n\t}\n\n\tpublic static float SampleMotionRate( MotionRateCurve curve, float fraction )\n\t{\n\t\tfraction = Math.Clamp( fraction, 0.0f, 1.0f );\n\t\tvar rate = Hermite(\n\t\t\tcurve.StartRate,\n\t\t\tcurve.EndRate,\n\t\t\tcurve.StartSlope,\n\t\t\tcurve.EndSlope,\n\t\t\tfraction );\n\t\treturn IsFinite( rate ) ? MathF.Max( rate, 0 ) : 0;\n\t}\n\n\tpublic static float SampleMotionProgress( MotionRateCurve curve, float fraction )\n\t{\n\t\tfraction = Math.Clamp( fraction, 0.0f, 1.0f );\n\t\tif ( fraction <= 0 )\n\t\t\treturn 0;\n\t\tif ( fraction >= 1 )\n\t\t\treturn 1;\n\n\t\tvar total = IntegrateMotionRate( curve, 1.0f );\n\t\tif ( total <= Epsilon || !IsFinite( total ) )\n\t\t\treturn fraction;\n\n\t\treturn Math.Clamp( IntegrateMotionRate( curve, fraction ) / total, 0.0f, 1.0f );\n\t}\n\n\tpublic static float MotionRateArea( MotionRateCurve curve ) =>\n\t\tIntegrateMotionRate( curve, 1.0f );\n\n\tpublic static float SnapTime( float time, float sampleRate, bool allowSubframes )\n\t{\n\t\tif ( allowSubframes || sampleRate <= Epsilon )\n\t\t\treturn MathF.Max( time, 0 );\n\n\t\treturn MathF.Max( MathF.Round( time * sampleRate ) / sampleRate, 0 );\n\t}\n\n\tpublic static TransformKey UpsertKey( TransformTrack track, float time, Transform value, float tolerance = 0.0001f )\n\t{\n\t\tvar existing = track.Keys.FirstOrDefault( x => MathF.Abs( x.Time - time ) <= tolerance );\n\t\tif ( existing is null )\n\t\t{\n\t\t\texisting = new TransformKey { Time = time };\n\t\t\ttrack.Keys.Add( existing );\n\t\t}\n\n\t\texisting.Position = value.Position;\n\t\texisting.Rotation = value.Rotation.Normal;\n\t\texisting.Scale = value.Scale;\n\t\ttrack.Keys.Sort( ( a, b ) => a.Time.CompareTo( b.Time ) );\n\t\treturn existing;\n\t}\n\n\tpublic static void RepairCurveSpans( TransformTrack track )\n\t{\n\t\tvar ordered = track.Keys.OrderBy( x => x.Time ).ToArray();\n\t\tvar adjacent = ordered\n\t\t\t.Zip( ordered.Skip( 1 ), ( start, end ) => (start.Id, end.Id) )\n\t\t\t.ToHashSet();\n\t\ttrack.CurveSpans.RemoveAll( span =>\n\t\t\tspan.StartKeyId == Guid.Empty\n\t\t\t|| span.EndKeyId == Guid.Empty\n\t\t\t|| !adjacent.Contains( (span.StartKeyId, span.EndKeyId) ) );\n\n\t\tforeach ( var duplicate in track.CurveSpans\n\t\t\t.GroupBy( x => (x.StartKeyId, x.EndKeyId) )\n\t\t\t.SelectMany( x => x.Skip( 1 ) )\n\t\t\t.ToArray() )\n\t\t{\n\t\t\ttrack.CurveSpans.Remove( duplicate );\n\t\t}\n\t}\n\n\tpublic static TwoBoneSolution SolveTwoBone(\n\t\tVector3 root,\n\t\tVector3 currentElbow,\n\t\tVector3 currentEnd,\n\t\tVector3 requestedTarget,\n\t\tVector3 pole )\n\t{\n\t\tvar upperLength = root.Distance( currentElbow );\n\t\tvar lowerLength = currentElbow.Distance( currentEnd );\n\t\tvar targetVector = requestedTarget - root;\n\t\tvar requestedDistance = targetVector.Length;\n\t\tvar direction = requestedDistance > Epsilon ? targetVector.Normal : Vector3.Forward;\n\t\tvar minimum = MathF.Abs( upperLength - lowerLength ) + Epsilon;\n\t\tvar maximum = MathF.Max( upperLength + lowerLength - Epsilon, minimum );\n\t\tvar solvedDistance = Math.Clamp( requestedDistance, minimum, maximum );\n\t\tvar reachable = requestedDistance >= minimum && requestedDistance <= maximum + Epsilon;\n\t\tvar solvedEnd = root + direction * solvedDistance;\n\n\t\tvar poleVector = pole - root;\n\t\tvar poleDirection = poleVector - direction * Vector3.Dot( poleVector, direction );\n\t\tif ( poleDirection.Length <= Epsilon )\n\t\t{\n\t\t\tvar fallback = MathF.Abs( Vector3.Dot( direction, Vector3.Up ) ) < 0.95f\n\t\t\t\t? Vector3.Up\n\t\t\t\t: Vector3.Left;\n\t\t\tpoleDirection = fallback - direction * Vector3.Dot( fallback, direction );\n\t\t}\n\n\t\tpoleDirection = poleDirection.Normal;\n\t\tvar along = (\n\t\t\tupperLength * upperLength\n\t\t\t- lowerLength * lowerLength\n\t\t\t+ solvedDistance * solvedDistance ) / (2.0f * solvedDistance);\n\t\tvar heightSquared = MathF.Max( upperLength * upperLength - along * along, 0 );\n\t\tvar elbow = root + direction * along + poleDirection * MathF.Sqrt( heightSquared );\n\t\treturn new TwoBoneSolution(\n\t\t\troot,\n\t\t\telbow,\n\t\t\tsolvedEnd,\n\t\t\treachable,\n\t\t\trequestedDistance,\n\t\t\tsolvedDistance );\n\t}\n\n\tpublic static Rotation RotationFromTo( Vector3 from, Vector3 to )\n\t{\n\t\tif ( from.Length <= Epsilon || to.Length <= Epsilon )\n\t\t\treturn Rotation.Identity;\n\n\t\tfrom = from.Normal;\n\t\tto = to.Normal;\n\t\tvar dot = Math.Clamp( Vector3.Dot( from, to ), -1.0f, 1.0f );\n\t\tvar axis = Vector3.Cross( from, to );\n\t\tif ( axis.Length <= Epsilon )\n\t\t{\n\t\t\tif ( dot >= 0 )\n\t\t\t\treturn Rotation.Identity;\n\n\t\t\tvar orthogonal = Vector3.Cross( from, Vector3.Up );\n\t\t\tif ( orthogonal.Length <= Epsilon )\n\t\t\t\torthogonal = Vector3.Cross( from, Vector3.Right );\n\t\t\treturn Rotation.FromAxis( orthogonal.Normal, 180.0f );\n\t\t}\n\n\t\treturn Rotation.FromAxis(\n\t\t\taxis.Normal,\n\t\t\tMathF.Acos( dot ).RadianToDegree() );\n\t}\n\n\tpublic static Transform Compose( Transform physical, Transform framing )\n\t{\n\t\tvar position = physical.PointToWorld( framing.Position );\n\t\tvar rotation = physical.Rotation * framing.Rotation;\n\t\tvar scale = physical.Scale * framing.Scale;\n\t\treturn new Transform( position, rotation, scale );\n\t}\n\n\tpublic static float ToCentimetres( float sboxUnits ) => sboxUnits * CentimetresPerInch;\n\n\tpublic static Vector3 AxisVector( WeaponUpAxis axis ) => axis switch\n\t{\n\t\tWeaponUpAxis.NegativeZ => Vector3.Down,\n\t\tWeaponUpAxis.PositiveY => Vector3.Left,\n\t\tWeaponUpAxis.NegativeY => Vector3.Right,\n\t\t_ => Vector3.Up\n\t};\n\n\tprivate static Transform KeyTransform( TransformKey key ) =>\n\t\tnew( key.Position, key.Rotation.Normal, key.Scale );\n\n\tprivate static float IntegrateMotionRate( MotionRateCurve curve, float end )\n\t{\n\t\tend = Math.Clamp( end, 0.0f, 1.0f );\n\t\tif ( end <= 0 )\n\t\t\treturn 0;\n\n\t\tvar step = 1.0f / MotionRateIntegrationSteps;\n\t\tvar wholeSteps = Math.Clamp(\n\t\t\t(int)MathF.Floor( end * MotionRateIntegrationSteps ),\n\t\t\t0,\n\t\t\tMotionRateIntegrationSteps );\n\t\tvar area = 0.0f;\n\t\tfor ( var index = 0; index < wholeSteps; index++ )\n\t\t{\n\t\t\tvar start = index * step;\n\t\t\tvar finish = (index + 1) * step;\n\t\t\tarea += (SampleMotionRate( curve, start ) + SampleMotionRate( curve, finish ))\n\t\t\t\t* 0.5f * step;\n\t\t}\n\n\t\tvar remainderStart = wholeSteps * step;\n\t\tif ( remainderStart < end )\n\t\t{\n\t\t\tarea += (SampleMotionRate( curve, remainderStart ) + SampleMotionRate( curve, end ))\n\t\t\t\t* 0.5f * (end - remainderStart);\n\t\t}\n\t\treturn area;\n\t}\n\n\tprivate static Vector3 SampleVectorChannels(\n\t\tVector3 start,\n\t\tVector3 end,\n\t\tVector3 startTangents,\n\t\tVector3 endTangents,\n\t\tTransformCurveChannel customChannels,\n\t\tTransformCurveChannel firstChannel,\n\t\tfloat progress,\n\t\tfloat duration,\n\t\tTrackInterpolation interpolation )\n\t{\n\t\tvar legacy = interpolation == TrackInterpolation.Cubic\n\t\t\t? SmoothStep( progress )\n\t\t\t: progress;\n\t\treturn new Vector3(\n\t\t\tSampleScalarChannel(\n\t\t\t\tstart.x, end.x, startTangents.x, endTangents.x,\n\t\t\t\t(customChannels & firstChannel) != 0, progress, legacy, duration ),\n\t\t\tSampleScalarChannel(\n\t\t\t\tstart.y, end.y, startTangents.y, endTangents.y,\n\t\t\t\t(customChannels & (TransformCurveChannel)((int)firstChannel << 1)) != 0,\n\t\t\t\tprogress, legacy, duration ),\n\t\t\tSampleScalarChannel(\n\t\t\t\tstart.z, end.z, startTangents.z, endTangents.z,\n\t\t\t\t(customChannels & (TransformCurveChannel)((int)firstChannel << 2)) != 0,\n\t\t\t\tprogress, legacy, duration ) );\n\t}\n\n\tprivate static float SampleScalarChannel(\n\t\tfloat start,\n\t\tfloat end,\n\t\tfloat startTangent,\n\t\tfloat endTangent,\n\t\tbool custom,\n\t\tfloat progress,\n\t\tfloat legacyProgress,\n\t\tfloat duration ) =>\n\t\tcustom\n\t\t\t? Hermite( start, end, startTangent * duration, endTangent * duration, progress )\n\t\t\t: start.LerpTo( end, legacyProgress );\n\n\tprivate static Rotation SampleRotationChannels(\n\t\tTransformKey current,\n\t\tTransformKey next,\n\t\tTransformCurveSpan span,\n\t\tfloat progress,\n\t\tfloat duration,\n\t\tTrackInterpolation interpolation )\n\t{\n\t\tvar custom = span.CustomChannels & TransformCurveChannel.Rotation;\n\t\tvar legacy = interpolation == TrackInterpolation.Cubic\n\t\t\t? SmoothStep( progress )\n\t\t\t: progress;\n\t\tif ( custom == TransformCurveChannel.None )\n\t\t\treturn Rotation.Slerp( current.Rotation, next.Rotation, legacy );\n\n\t\tvar startAngles = current.Rotation.Angles();\n\t\tvar endAngles = next.Rotation.Angles();\n\t\tvar start = new Vector3( startAngles.pitch, startAngles.yaw, startAngles.roll );\n\t\tvar end = new Vector3(\n\t\t\tUnwrapDegrees( start.x, endAngles.pitch ),\n\t\t\tUnwrapDegrees( start.y, endAngles.yaw ),\n\t\t\tUnwrapDegrees( start.z, endAngles.roll ) );\n\t\tvar legacyRotation = Rotation.Slerp( current.Rotation, next.Rotation, legacy );\n\t\tvar legacyAngles = legacyRotation.Angles();\n\t\tvar legacyValues = new Vector3(\n\t\t\tUnwrapDegrees( start.x, legacyAngles.pitch ),\n\t\t\tUnwrapDegrees( start.y, legacyAngles.yaw ),\n\t\t\tUnwrapDegrees( start.z, legacyAngles.roll ) );\n\t\tvar sampled = new Vector3(\n\t\t\t(custom & TransformCurveChannel.RotationX) != 0\n\t\t\t\t? Hermite(\n\t\t\t\t\tstart.x,\n\t\t\t\t\tend.x,\n\t\t\t\t\tcurrent.CurveTangents.RotationOut.x * duration,\n\t\t\t\t\tnext.CurveTangents.RotationIn.x * duration,\n\t\t\t\t\tprogress )\n\t\t\t\t: legacyValues.x,\n\t\t\t(custom & TransformCurveChannel.RotationY) != 0\n\t\t\t\t? Hermite(\n\t\t\t\t\tstart.y,\n\t\t\t\t\tend.y,\n\t\t\t\t\tcurrent.CurveTangents.RotationOut.y * duration,\n\t\t\t\t\tnext.CurveTangents.RotationIn.y * duration,\n\t\t\t\t\tprogress )\n\t\t\t\t: legacyValues.y,\n\t\t\t(custom & TransformCurveChannel.RotationZ) != 0\n\t\t\t\t? Hermite(\n\t\t\t\t\tstart.z,\n\t\t\t\t\tend.z,\n\t\t\t\t\tcurrent.CurveTangents.RotationOut.z * duration,\n\t\t\t\t\tnext.CurveTangents.RotationIn.z * duration,\n\t\t\t\t\tprogress )\n\t\t\t\t: legacyValues.z );\n\t\treturn Rotation.From( new Angles( sampled.x, sampled.y, sampled.z ) ).Normal;\n\t}\n\n\tprivate static float Hermite(\n\t\tfloat start,\n\t\tfloat end,\n\t\tfloat startTangent,\n\t\tfloat endTangent,\n\t\tfloat amount )\n\t{\n\t\tvar amount2 = amount * amount;\n\t\tvar amount3 = amount2 * amount;\n\t\treturn (2 * amount3 - 3 * amount2 + 1) * start\n\t\t\t+ (amount3 - 2 * amount2 + amount) * startTangent\n\t\t\t+ (-2 * amount3 + 3 * amount2) * end\n\t\t\t+ (amount3 - amount2) * endTangent;\n\t}\n\n\tprivate static float SmoothStep( float amount ) =>\n\t\tamount * amount * (3.0f - 2.0f * amount);\n\n\tprivate static float UnwrapDegrees( float reference, float value )\n\t{\n\t\tvar difference = (value - reference) % 360.0f;\n\t\tif ( difference > 180 )\n\t\t\tdifference -= 360;\n\t\telse if ( difference < -180 )\n\t\t\tdifference += 360;\n\t\treturn reference + difference;\n\t}\n}\n\npublic static class ClipConstraintEvaluator\n{\n\tpublic static Transform Apply(\n\t\tTransform source,\n\t\tTransform target,\n\t\tTimedConstraint constraint,\n\t\tfloat time,\n\t\tTransform maintainedOffset )\n\t{\n\t\tif ( time < constraint.StartTime || time > constraint.EndTime || constraint.Weight <= 0 )\n\t\t\treturn source;\n\n\t\tvar desired = constraint.MaintainOffset\n\t\t\t? new Transform(\n\t\t\t\ttarget.PointToWorld( maintainedOffset.Position ),\n\t\t\t\ttarget.Rotation * maintainedOffset.Rotation,\n\t\t\t\ttarget.Scale * maintainedOffset.Scale )\n\t\t\t: target;\n\n\t\tvar weight = Math.Clamp( constraint.Weight, 0.0f, 1.0f );\n\t\treturn new Transform(\n\t\t\tVector3.Lerp( source.Position, desired.Position, weight ),\n\t\t\tRotation.Slerp( source.Rotation, desired.Rotation, weight ),\n\t\t\tVector3.Lerp( source.Scale, desired.Scale, weight ) );\n\t}\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Runtime/WeaponAnimationDocument.cs",
            "FileName": "WeaponAnimationDocument.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\nusing System.Text.Json.Serialization;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator;\n\npublic enum WeaponAnimatorStage\n{\n\tCalibrate = 1,\n\tAnimate = 2\n}\n\npublic enum WeaponBoneClassification\n{\n\tWeaponRoot,\n\tAnimatable,\n\tStructural,\n\tIgnored\n}\n\npublic enum WeaponBoneInclusion\n{\n\tIncluded,\n\tStructuralBridge,\n\tExcluded\n}\n\npublic enum MeasurementUnit\n{\n\tInches,\n\tCentimetres\n}\n\npublic enum WeaponUpAxis\n{\n\tPositiveZ,\n\tNegativeZ,\n\tPositiveY,\n\tNegativeY\n}\n\npublic enum ClipReadiness\n{\n\tNotStarted,\n\tDraft,\n\tReady,\n\tWarning\n}\n\npublic enum WeaponClipRole\n{\n\tCustom,\n\tIdle,\n\tDeploy,\n\tFire,\n\tFireDry,\n\tReload,\n\tReloadEmpty,\n\tHolster,\n\tInspect,\n\tSprint,\n\tJump,\n\tLower,\n\tIronsights,\n\tGrabStance,\n\tGrabGestureOne,\n\tGrabGestureTwo,\n\tGrabGestureThree,\n\tGrabGestureFour,\n\tReloadEnter,\n\tFirstShell,\n\tInsertShell,\n\tReloadExit\n}\n\npublic enum TrackInterpolation\n{\n\tStepped,\n\tLinear,\n\tCubic\n}\n\npublic enum CurveEditorMode\n{\n\tSpeed,\n\tChannels\n}\n\n[Flags]\npublic enum TransformCurveChannel\n{\n\tNone = 0,\n\tPositionX = 1 << 0,\n\tPositionY = 1 << 1,\n\tPositionZ = 1 << 2,\n\tRotationX = 1 << 3,\n\tRotationY = 1 << 4,\n\tRotationZ = 1 << 5,\n\tScaleX = 1 << 6,\n\tScaleY = 1 << 7,\n\tScaleZ = 1 << 8,\n\tPosition = PositionX | PositionY | PositionZ,\n\tRotation = RotationX | RotationY | RotationZ,\n\tScale = ScaleX | ScaleY | ScaleZ,\n\tAll = Position | Rotation | Scale\n}\n\npublic enum CurveHandleMode\n{\n\tAligned,\n\tFree\n}\n\npublic enum AnimationTagKind\n{\n\tPoint,\n\tRange\n}\n\npublic enum ReloadProfile\n{\n\tMagazine,\n\tIncremental\n}\n\npublic enum GripConfiguration\n{\n\tOneHanded,\n\tTwoHanded\n}\n\npublic enum AnchorKind\n{\n\tGrip,\n\tRearBore,\n\tFrontBore,\n\tMuzzle,\n\tEject,\n\tCustom\n}\n\npublic enum RigControlKind\n{\n\tArm,\n\tWeapon,\n\tCamera\n}\n\npublic enum VisibilityRenderMode\n{\n\tBoneBranch,\n\tBodyGroup\n}\n\npublic enum WeaponTextureChannel\n{\n\tBaseColor,\n\tNormal,\n\tRoughness,\n\tMetalness,\n\tAmbientOcclusion,\n\tPackedOrm\n}\n\npublic sealed class WeaponAnimationDocument\n{\n\tpublic const int CurrentSchemaVersion = 4;\n\n\tpublic int SchemaVersion { get; set; } = CurrentSchemaVersion;\n\tpublic Guid DocumentId { get; set; } = Guid.NewGuid();\n\tpublic string Name { get; set; } = \"New Weapon\";\n\tpublic WeaponAnimatorStage ActiveStage { get; set; } = WeaponAnimatorStage.Calibrate;\n\tpublic SourceModelSettings Source { get; set; } = new();\n\tpublic WeaponRigDefinition Rig { get; set; } = new();\n\tpublic WeaponCalibration Calibration { get; set; } = new();\n\tpublic ArmBindingDefinition Binding { get; set; } = new();\n\tpublic List<WeaponAnimationClip> Clips { get; set; } = [];\n\tpublic AnimGraphSettings Graph { get; set; } = new();\n\tpublic OutputSettings Output { get; set; } = new();\n\tpublic WorkspaceState Workspace { get; set; } = new();\n\n\t// Ownership is persisted beside generated assets. Keeping file-like strings out of the\n\t// GameResource prevents the asset compiler from treating manifest entries as dependencies.\n\t[JsonIgnore]\n\tpublic GenerationManifest Manifest { get; set; } = new();\n\n\tpublic static WeaponAnimationDocument CreateDefault( string name = \"New Weapon\" )\n\t{\n\t\tvar document = new WeaponAnimationDocument\n\t\t{\n\t\t\tName = name,\n\t\t\tOutput = new OutputSettings\n\t\t\t{\n\t\t\t\tAssetName = Slugify( name )\n\t\t\t}\n\t\t};\n\n\t\tdocument.Clips = StandardClips()\n\t\t\t.Select( role => WeaponAnimationClip.Create( role ) )\n\t\t\t.ToList();\n\n\t\tdocument.Workspace.SelectedClipId = document.Clips\n\t\t\t.First( x => x.Role == WeaponClipRole.Idle ).Id;\n\n\t\treturn document;\n\t}\n\n\tpublic WeaponAnimationClip? GetSelectedClip()\n\t{\n\t\treturn Clips.FirstOrDefault( x => x.Id == Workspace.SelectedClipId )\n\t\t\t?? Clips.FirstOrDefault();\n\t}\n\n\tpublic WeaponAnimationClip EnsureClip( WeaponClipRole role )\n\t{\n\t\tvar clip = Clips.FirstOrDefault( x => x.Role == role );\n\t\tif ( clip is not null )\n\t\t\treturn clip;\n\n\t\tclip = WeaponAnimationClip.Create( role );\n\t\tClips.Add( clip );\n\t\treturn clip;\n\t}\n\n\tpublic static IReadOnlyList<WeaponClipRole> StandardClips() =>\n\t[\n\t\tWeaponClipRole.Idle,\n\t\tWeaponClipRole.Deploy,\n\t\tWeaponClipRole.Fire,\n\t\tWeaponClipRole.FireDry,\n\t\tWeaponClipRole.Reload,\n\t\tWeaponClipRole.ReloadEmpty,\n\t\tWeaponClipRole.Holster,\n\t\tWeaponClipRole.Inspect,\n\t\tWeaponClipRole.Sprint,\n\t\tWeaponClipRole.Jump,\n\t\tWeaponClipRole.Lower,\n\t\tWeaponClipRole.Ironsights,\n\t\tWeaponClipRole.GrabStance,\n\t\tWeaponClipRole.GrabGestureOne,\n\t\tWeaponClipRole.GrabGestureTwo,\n\t\tWeaponClipRole.GrabGestureThree,\n\t\tWeaponClipRole.GrabGestureFour,\n\t\tWeaponClipRole.ReloadEnter,\n\t\tWeaponClipRole.FirstShell,\n\t\tWeaponClipRole.InsertShell,\n\t\tWeaponClipRole.ReloadExit\n\t];\n\n\tpublic static string Slugify( string value )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( value ) )\n\t\t\treturn \"weapon\";\n\n\t\tvar chars = value.Trim().ToLowerInvariant()\n\t\t\t.Select( c => char.IsLetterOrDigit( c ) ? c : '_' )\n\t\t\t.ToArray();\n\n\t\treturn string.Join( \"_\", new string( chars )\n\t\t\t.Split( '_', StringSplitOptions.RemoveEmptyEntries ) );\n\t}\n}\n\npublic sealed class SourceModelSettings\n{\n\tpublic string OriginalSourcePath { get; set; } = \"\";\n\tpublic string SourcePath { get; set; } = \"\";\n\tpublic string CompiledModelPath { get; set; } = \"\";\n\tpublic string PreviewHostPath { get; set; } = \"\";\n\tpublic string SourceHash { get; set; } = \"\";\n\tpublic string SourceRootBoneName { get; set; } = \"\";\n\tpublic Vector3 OriginalModelDimensions { get; set; }\n\tpublic bool NeedsModelDocWrapper { get; set; }\n\tpublic bool Compiled { get; set; }\n\tpublic bool PreviewHostCompiled { get; set; }\n\tpublic DateTime LastImportedUtc { get; set; }\n\tpublic List<SourceMaterialBinding> Materials { get; set; } = [];\n}\n\npublic sealed class SourceMaterialBinding\n{\n\t// Stored without a resource extension so the .wepanim compiler does not treat an\n\t// imported FBX slot label as a project asset dependency.\n\tpublic string SourceMaterialPath { get; set; } = \"\";\n\tpublic string Name { get; set; } = \"\";\n\tpublic string OutputName { get; set; } = \"\";\n\n\t[JsonIgnore]\n\tpublic string PreviewMaterialPath { get; set; } = \"\";\n\tpublic List<SourceTextureMap> Textures { get; set; } = [];\n\n\tpublic SourceTextureMap? FindTexture( WeaponTextureChannel channel ) =>\n\t\tTextures.FirstOrDefault( texture => texture.Channel == channel );\n\n\tpublic bool HasUsableTextures =>\n\t\tTextures.Any( texture => texture.Channel != WeaponTextureChannel.PackedOrm\n\t\t\t&& !string.IsNullOrWhiteSpace( texture.AssetPath ) );\n}\n\npublic sealed class SourceTextureMap\n{\n\tpublic WeaponTextureChannel Channel { get; set; }\n\n\t[JsonIgnore]\n\tpublic string OriginalPath { get; set; } = \"\";\n\tpublic string AssetPath { get; set; } = \"\";\n\tpublic string Sha256 { get; set; } = \"\";\n}\n\npublic sealed class WeaponRigDefinition\n{\n\tpublic string RootBone { get; set; } = \"\";\n\tpublic string SourceSkeletonRootId { get; set; } = \"\";\n\tpublic string WeaponSubtreeRootId { get; set; } = \"\";\n\tpublic List<WeaponBoneDefinition> Bones { get; set; } = [];\n\tpublic List<WeaponVisibilityPart> VisibilityParts { get; set; } = [];\n\tpublic List<RigAuditIssue> AuditIssues { get; set; } = [];\n\tpublic string ProfileHash { get; set; } = \"\";\n\tpublic bool ReviewRequired { get; set; }\n\tpublic bool FilteredPreviewConfirmed { get; set; }\n\n\tpublic WeaponBoneDefinition? FindBone( string idOrName ) =>\n\t\tBones.FirstOrDefault( x =>\n\t\t\tstring.Equals( x.Id, idOrName, StringComparison.OrdinalIgnoreCase )\n\t\t\t|| string.Equals( x.Name, idOrName, StringComparison.OrdinalIgnoreCase ) );\n\n\tpublic IEnumerable<WeaponBoneDefinition> RetainedBones() =>\n\t\tBones.Where( x => x.Inclusion != WeaponBoneInclusion.Excluded\n\t\t\t&& x.Classification != WeaponBoneClassification.Ignored );\n}\n\npublic sealed class WeaponVisibilityPart\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string Name { get; set; } = \"Visible Part\";\n\tpublic string BoneId { get; set; } = \"\";\n\tpublic string BoneName { get; set; } = \"\";\n\tpublic bool DefaultVisible { get; set; } = true;\n\tpublic VisibilityRenderMode RenderMode { get; set; } = VisibilityRenderMode.BoneBranch;\n\tpublic string BodyGroupName { get; set; } = \"\";\n\tpublic int VisibleBodyGroupValue { get; set; } = 1;\n\tpublic int HiddenBodyGroupValue { get; set; }\n}\n\npublic sealed class WeaponBoneDefinition\n{\n\tpublic string Id { get; set; } = \"\";\n\tpublic string ParentId { get; set; } = \"\";\n\tpublic string HierarchyPath { get; set; } = \"\";\n\tpublic string Name { get; set; } = \"\";\n\tpublic string ParentName { get; set; } = \"\";\n\tpublic string OriginalName { get; set; } = \"\";\n\tpublic string OriginalParentName { get; set; } = \"\";\n\tpublic WeaponBoneClassification Classification { get; set; } = WeaponBoneClassification.Animatable;\n\tpublic WeaponBoneInclusion Inclusion { get; set; } = WeaponBoneInclusion.Included;\n\n\t// BindTransform is retained for loading version 2 projects.\n\tpublic Transform BindTransform { get; set; } = Transform.Zero;\n\tpublic Transform BindModelTransform { get; set; } = Transform.Zero;\n\tpublic Transform BindLocalTransform { get; set; } = Transform.Zero;\n\tpublic bool HasSkinInfluence { get; set; }\n}\n\npublic sealed class RigAuditIssue\n{\n\tpublic string Code { get; set; } = \"\";\n\tpublic string Message { get; set; } = \"\";\n\tpublic ValidationSeverity Severity { get; set; } = ValidationSeverity.Warning;\n\tpublic string BoneName { get; set; } = \"\";\n}\n\npublic sealed class WeaponCalibration\n{\n\tpublic float UniformScale { get; set; } = 1.0f;\n\tpublic Transform PhysicalTransform { get; set; } = Transform.Zero;\n\tpublic Transform FramingTransform { get; set; } = Transform.Zero;\n\tpublic ScaleMeasurement Measurement { get; set; } = new();\n\tpublic List<WeaponAnchor> Anchors { get; set; } = [];\n\tpublic WeaponUpAxis UpAxis { get; set; } = WeaponUpAxis.PositiveZ;\n\tpublic float HorizontalFov { get; set; } = 80.0f;\n\tpublic string AspectGuide { get; set; } = \"16:9\";\n\tpublic bool ShowSafeArea { get; set; } = true;\n\tpublic bool ShowCrosshair { get; set; } = true;\n\tpublic bool Confirmed { get; set; }\n\tpublic int Revision { get; set; }\n\tpublic CalibrationSnapshot? Snapshot { get; set; }\n\n\t/// <summary>\n\t/// Resolves the single anchor of a fixed kind. Custom anchors are identified by id instead,\n\t/// because a weapon may carry several of them.\n\t/// </summary>\n\tpublic WeaponAnchor? GetAnchor( AnchorKind kind ) =>\n\t\tAnchors.FirstOrDefault( x => x.Kind == kind );\n\n\tpublic WeaponAnchor? FindAnchor( Guid id ) =>\n\t\tAnchors.FirstOrDefault( x => x.Id == id );\n\n\tpublic IEnumerable<WeaponAnchor> CustomAnchors() =>\n\t\tAnchors.Where( x => x.Kind == AnchorKind.Custom );\n\n\tpublic void SetAnchor( WeaponAnchor anchor )\n\t{\n\t\tvar existing = anchor.Kind == AnchorKind.Custom\n\t\t\t? FindAnchor( anchor.Id )\n\t\t\t: GetAnchor( anchor.Kind );\n\t\tif ( existing is null )\n\t\t\tAnchors.Add( anchor );\n\t\telse\n\t\t{\n\t\t\texisting.Name = anchor.Name;\n\t\t\texisting.BoneName = anchor.BoneName;\n\t\t\texisting.LocalPosition = anchor.LocalPosition;\n\t\t\texisting.LocalRotation = anchor.LocalRotation;\n\t\t}\n\t}\n}\n\npublic sealed class ScaleMeasurement\n{\n\tpublic bool HasFirstPoint { get; set; }\n\tpublic bool HasSecondPoint { get; set; }\n\tpublic Vector3 FirstPoint { get; set; }\n\tpublic Vector3 SecondPoint { get; set; }\n\tpublic string FirstBone { get; set; } = \"\";\n\tpublic string SecondBone { get; set; } = \"\";\n\tpublic float KnownDistance { get; set; }\n\tpublic MeasurementUnit Unit { get; set; } = MeasurementUnit.Inches;\n\tpublic float PreviewScale { get; set; } = 1.0f;\n\tpublic bool HasPendingScale { get; set; }\n\tpublic Vector3 OriginalDimensions { get; set; }\n\tpublic Vector3 ResultingDimensions { get; set; }\n}\n\npublic sealed class WeaponAnchor\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string Name { get; set; } = \"\";\n\n\t/// <summary>\n\t/// Attachment name emitted for custom anchors. Stored rather than derived so renaming the\n\t/// anchor cannot silently rename an attachment that game code already references.\n\t/// </summary>\n\tpublic string GeneratedAttachmentName { get; set; } = \"\";\n\tpublic AnchorKind Kind { get; set; }\n\tpublic string BoneName { get; set; } = \"\";\n\tpublic Vector3 LocalPosition { get; set; }\n\tpublic Rotation LocalRotation { get; set; } = Rotation.Identity;\n}\n\npublic sealed class CalibrationSnapshot\n{\n\tpublic int Revision { get; set; }\n\tpublic string SourceHash { get; set; } = \"\";\n\tpublic string RigHash { get; set; } = \"\";\n\tpublic float UniformScale { get; set; } = 1.0f;\n\tpublic Transform PhysicalTransform { get; set; } = Transform.Zero;\n\tpublic Transform FramingTransform { get; set; } = Transform.Zero;\n\tpublic List<WeaponAnchor> Anchors { get; set; } = [];\n\tpublic DateTime ConfirmedUtc { get; set; }\n}\n\npublic sealed class ArmBindingDefinition\n{\n\tpublic string Profile { get; set; } = \"FacepunchHumanV1\";\n\tpublic string ArmsModel { get; set; } = \"models/first_person/v_first_person_arms_human.vmdl\";\n\tpublic GripConfiguration Configuration { get; set; } = GripConfiguration.TwoHanded;\n\tpublic RigTarget PrimaryHand { get; set; } = RigTarget.Create( \"Primary Hand\", true );\n\tpublic RigTarget SupportHand { get; set; } = RigTarget.Create( \"Support Hand\", false );\n\tpublic RigTarget PrimaryElbowPole { get; set; } = RigTarget.CreatePole( \"Primary Elbow\" );\n\tpublic RigTarget SupportElbowPole { get; set; } = RigTarget.CreatePole( \"Support Elbow\" );\n\tpublic List<GripPose> GripPoses { get; set; } = [];\n\tpublic Guid DefaultGripPoseId { get; set; }\n\tpublic bool ChecklistDismissed { get; set; }\n\tpublic List<string> CompletedChecklistItems { get; set; } = [];\n}\n\npublic sealed class RigTarget\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string Name { get; set; } = \"\";\n\tpublic RigControlKind Kind { get; set; } = RigControlKind.Arm;\n\tpublic Transform Transform { get; set; } = Transform.Zero;\n\tpublic string AttachedBone { get; set; } = \"\";\n\tpublic bool IsPrimary { get; set; }\n\tpublic bool IsBound { get; set; }\n\tpublic bool Reachable { get; set; } = true;\n\n\tpublic static RigTarget Create( string name, bool primary ) => new()\n\t{\n\t\tName = name,\n\t\tIsPrimary = primary,\n\t\tTransform = new Transform( new Vector3( 12, primary ? -3 : 3, -2 ) )\n\t};\n\n\tpublic static RigTarget CreatePole( string name ) => new()\n\t{\n\t\tName = name,\n\t\tTransform = new Transform( new Vector3( 5, name.Contains( \"Primary\" ) ? -12 : 12, -5 ) )\n\t};\n}\n\npublic sealed class GripPose\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string Name { get; set; } = \"Default Grip\";\n\tpublic List<BonePose> Bones { get; set; } = [];\n}\n\npublic sealed class BonePose\n{\n\tpublic string BoneName { get; set; } = \"\";\n\tpublic Transform LocalTransform { get; set; } = Transform.Zero;\n}\n\npublic sealed class WeaponAnimationClip\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string Name { get; set; } = \"Custom\";\n\tpublic WeaponClipRole Role { get; set; }\n\t// Generated Idle bind poses can follow calibration changes until deliberately authored.\n\tpublic bool IsBindPoseSeed { get; set; }\n\tpublic ClipReadiness Readiness { get; set; } = ClipReadiness.NotStarted;\n\tpublic float Duration { get; set; } = 1.0f;\n\tpublic float SampleRate { get; set; } = 30.0f;\n\tpublic bool AllowSubframeKeys { get; set; }\n\tpublic bool Loop { get; set; }\n\tpublic string GeneratedSequenceName { get; set; } = \"\";\n\tpublic List<TransformTrack> Tracks { get; set; } = [];\n\tpublic List<VisibilityTrack> VisibilityTracks { get; set; } = [];\n\tpublic List<TimedConstraint> Constraints { get; set; } = [];\n\tpublic List<AnimationTag> Tags { get; set; } = [];\n\tpublic List<ClipParameterEvent> ParameterEvents { get; set; } = [];\n\tpublic string ImportedSequence { get; set; } = \"\";\n\n\tpublic static WeaponAnimationClip Create( WeaponClipRole role ) => new()\n\t{\n\t\tName = WeaponAnimationNames.DisplayName( role ),\n\t\tRole = role,\n\t\tLoop = role is WeaponClipRole.Idle or WeaponClipRole.Sprint\n\t};\n\n\tpublic TransformTrack EnsureTrack( string target )\n\t{\n\t\tvar track = Tracks.FirstOrDefault( x => x.Target == target );\n\t\tif ( track is not null )\n\t\t\treturn track;\n\n\t\ttrack = new TransformTrack { Target = target };\n\t\tTracks.Add( track );\n\t\treturn track;\n\t}\n\n\tpublic VisibilityTrack EnsureVisibilityTrack( Guid partId )\n\t{\n\t\tvar track = VisibilityTracks.FirstOrDefault( x => x.PartId == partId );\n\t\tif ( track is not null )\n\t\t\treturn track;\n\n\t\ttrack = new VisibilityTrack { PartId = partId };\n\t\tVisibilityTracks.Add( track );\n\t\treturn track;\n\t}\n}\n\npublic sealed class VisibilityTrack\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic Guid PartId { get; set; }\n\tpublic List<VisibilityKey> Keys { get; set; } = [];\n\tpublic bool Muted { get; set; }\n}\n\npublic sealed class VisibilityKey\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic float Time { get; set; }\n\tpublic bool Visible { get; set; } = true;\n}\n\npublic sealed class TransformTrack\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string Target { get; set; } = \"\";\n\tpublic RigControlKind Kind { get; set; } = RigControlKind.Weapon;\n\tpublic TrackInterpolation Interpolation { get; set; } = TrackInterpolation.Cubic;\n\tpublic List<TransformKey> Keys { get; set; } = [];\n\tpublic List<TransformCurveSpan> CurveSpans { get; set; } = [];\n\tpublic bool Muted { get; set; }\n\n\tpublic TransformCurveSpan? FindCurveSpan( Guid startKeyId, Guid endKeyId ) =>\n\t\tCurveSpans.FirstOrDefault( x =>\n\t\t\tx.StartKeyId == startKeyId && x.EndKeyId == endKeyId );\n\n\tpublic TransformCurveSpan EnsureCurveSpan( Guid startKeyId, Guid endKeyId )\n\t{\n\t\tvar span = FindCurveSpan( startKeyId, endKeyId );\n\t\tif ( span is not null )\n\t\t\treturn span;\n\n\t\tspan = new TransformCurveSpan\n\t\t{\n\t\t\tStartKeyId = startKeyId,\n\t\t\tEndKeyId = endKeyId\n\t\t};\n\t\tCurveSpans.Add( span );\n\t\treturn span;\n\t}\n}\n\npublic sealed class TransformKey\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic float Time { get; set; }\n\tpublic Vector3 Position { get; set; }\n\tpublic Rotation Rotation { get; set; } = Rotation.Identity;\n\tpublic Vector3 Scale { get; set; } = Vector3.One;\n\t// Retained for schema-v3 compatibility; migrated into CurveTangents.\n\tpublic Vector3 InTangent { get; set; }\n\tpublic Vector3 OutTangent { get; set; }\n\tpublic TransformCurveTangents CurveTangents { get; set; } = new();\n}\n\npublic sealed class TransformCurveSpan\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic Guid StartKeyId { get; set; }\n\tpublic Guid EndKeyId { get; set; }\n\tpublic bool HasSpeedCurve { get; set; }\n\tpublic MotionRateCurve Speed { get; set; } = new();\n\tpublic bool HasInterpolationOverride { get; set; }\n\tpublic TrackInterpolation Interpolation { get; set; } = TrackInterpolation.Linear;\n\tpublic TransformCurveChannel CustomChannels { get; set; }\n}\n\npublic sealed class MotionRateCurve\n{\n\tpublic float StartRate { get; set; } = 1.0f;\n\tpublic float EndRate { get; set; } = 1.0f;\n\tpublic float StartSlope { get; set; }\n\tpublic float EndSlope { get; set; }\n\tpublic CurveHandleMode StartHandleMode { get; set; } = CurveHandleMode.Aligned;\n\tpublic CurveHandleMode EndHandleMode { get; set; } = CurveHandleMode.Aligned;\n}\n\npublic sealed class TransformCurveTangents\n{\n\tpublic Vector3 PositionIn { get; set; }\n\tpublic Vector3 PositionOut { get; set; }\n\tpublic Vector3 RotationIn { get; set; }\n\tpublic Vector3 RotationOut { get; set; }\n\tpublic Vector3 ScaleIn { get; set; }\n\tpublic Vector3 ScaleOut { get; set; }\n\tpublic TransformCurveChannel FreeHandles { get; set; }\n}\n\npublic sealed class TimedConstraint\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string SourceControl { get; set; } = \"\";\n\tpublic string TargetBone { get; set; } = \"\";\n\tpublic float StartTime { get; set; }\n\tpublic float EndTime { get; set; } = 1.0f;\n\tpublic float Weight { get; set; } = 1.0f;\n\tpublic bool MaintainOffset { get; set; } = true;\n}\n\npublic sealed class AnimationTag\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string Name { get; set; } = \"\";\n\tpublic AnimationTagKind Kind { get; set; }\n\tpublic float StartTime { get; set; }\n\tpublic float EndTime { get; set; }\n}\n\npublic sealed class ClipParameterEvent\n{\n\tpublic string Name { get; set; } = \"\";\n\tpublic float Time { get; set; }\n\tpublic float Value { get; set; }\n}\n\npublic sealed class AnimGraphSettings\n{\n\tpublic bool GenerateGraph { get; set; } = true;\n\tpublic string ParameterProfile { get; set; } = \"FacepunchHumanV1\";\n\tpublic ReloadProfile ReloadProfile { get; set; } = ReloadProfile.Magazine;\n\tpublic bool FirearmProfile { get; set; } = true;\n\tpublic Dictionary<string, float> PreviewFloats { get; set; } = [];\n\tpublic Dictionary<string, bool> PreviewBools { get; set; } = [];\n}\n\npublic sealed class OutputSettings\n{\n\tpublic string AssetName { get; set; } = \"weapon\";\n\tpublic string OutputFolder { get; set; } = \"\";\n\tpublic bool GeneratePrefab { get; set; } = true;\n\tpublic bool GenerateGraph { get; set; } = true;\n\tpublic bool IncludeDebugSkeleton { get; set; }\n\n\tpublic string GetDefaultRelativeFolder()\n\t{\n\t\tvar slug = WeaponAnimationDocument.Slugify( AssetName );\n\t\treturn $\"weapons/{slug}/viewmodel\";\n\t}\n}\n\npublic sealed class WorkspaceState\n{\n\tpublic Guid SelectedClipId { get; set; }\n\tpublic float TimelineTime { get; set; }\n\tpublic string SelectedBone { get; set; } = \"\";\n\tpublic string SelectedControl { get; set; } = \"\";\n\tpublic string ConstraintTargetBone { get; set; } = \"\";\n\tpublic bool FirstPersonPreview { get; set; }\n\tpublic bool ShowGuides { get; set; }\n\tpublic bool ShowSkeleton { get; set; } = true;\n\tpublic bool XRaySkeleton { get; set; } = true;\n\tpublic bool BoneOcclusionEnabled { get; set; } = true;\n\tpublic bool ShowIkBones { get; set; }\n\tpublic bool ShowOnionSkins { get; set; }\n\tpublic float GridOpacity { get; set; } = 0.10f;\n\tpublic float GridLineThickness { get; set; } = 0.65f;\n\tpublic bool RimLightEnabled { get; set; } = true;\n\tpublic float RimLightIntensity { get; set; } = 4.0f;\n\tpublic bool AutoKey { get; set; } = true;\n\tpublic bool LocalGizmos { get; set; } = true;\n\tpublic bool SnapPosition { get; set; } = true;\n\tpublic bool SnapRotation { get; set; } = true;\n\tpublic float RotationSnapDegrees { get; set; } = 15.0f;\n\tpublic bool CurveEditorVisible { get; set; }\n\tpublic List<WorkingPoseOverride> WorkingPoseOverrides { get; set; } = [];\n\tpublic List<TimelineViewState> TimelineViews { get; set; } = [];\n\tpublic List<CurveViewState> CurveViews { get; set; } = [];\n\tpublic Vector3 CameraFocus { get; set; }\n\tpublic Angles CameraAngles { get; set; } = new( 12, 180, 0 );\n\tpublic float CameraDistance { get; set; } = 48.0f;\n\tpublic bool FreeLookCamera { get; set; }\n\tpublic Vector3 CameraPosition { get; set; }\n\tpublic float CameraMoveSpeed { get; set; } = 1.0f;\n\tpublic bool FullBrightViewport { get; set; }\n\tpublic string CalibrationSplitterState { get; set; } = \"\";\n\tpublic string AnimationSplitterState { get; set; } = \"\";\n\tpublic string CalibrationVerticalSplitterState { get; set; } = \"\";\n\tpublic string AnimationVerticalSplitterState { get; set; } = \"\";\n\tpublic string AnimationTimelineSplitterState { get; set; } = \"\";\n\tpublic string AnimationRightSplitterState { get; set; } = \"\";\n\tpublic string AnimationMainSplitterState { get; set; } = \"\";\n\tpublic string AnimationOuterSplitterState { get; set; } = \"\";\n\n\tpublic TimelineViewState? GetTimelineView( Guid clipId ) =>\n\t\tTimelineViews.FirstOrDefault( x => x.ClipId == clipId );\n\n\tpublic TimelineViewState EnsureTimelineView( Guid clipId, float duration )\n\t{\n\t\tvar existing = GetTimelineView( clipId );\n\t\tif ( existing is not null )\n\t\t\treturn existing;\n\n\t\texisting = new TimelineViewState\n\t\t{\n\t\t\tClipId = clipId,\n\t\t\tVisibleEnd = MathF.Max( duration, 0 )\n\t\t};\n\t\tTimelineViews.Add( existing );\n\t\treturn existing;\n\t}\n\n\tpublic CurveViewState? GetCurveView( Guid clipId ) =>\n\t\tCurveViews.FirstOrDefault( x => x.ClipId == clipId );\n\n\tpublic CurveViewState EnsureCurveView( Guid clipId )\n\t{\n\t\tvar existing = GetCurveView( clipId );\n\t\tif ( existing is not null )\n\t\t\treturn existing;\n\n\t\texisting = new CurveViewState { ClipId = clipId };\n\t\tCurveViews.Add( existing );\n\t\treturn existing;\n\t}\n\n\tpublic WorkingPoseOverride? GetWorkingPose( Guid clipId, string target ) =>\n\t\tWorkingPoseOverrides.FirstOrDefault( x =>\n\t\t\tx.ClipId == clipId\n\t\t\t&& x.Target.Equals( target, StringComparison.OrdinalIgnoreCase ) );\n\n\tpublic void SetWorkingPose(\n\t\tGuid clipId,\n\t\tstring target,\n\t\tRigControlKind kind,\n\t\tTransform transform )\n\t{\n\t\tvar existing = GetWorkingPose( clipId, target );\n\t\tif ( existing is null )\n\t\t{\n\t\t\tWorkingPoseOverrides.Add( new WorkingPoseOverride\n\t\t\t{\n\t\t\t\tClipId = clipId,\n\t\t\t\tTarget = target,\n\t\t\t\tKind = kind,\n\t\t\t\tTransform = transform\n\t\t\t} );\n\t\t\treturn;\n\t\t}\n\n\t\texisting.Kind = kind;\n\t\texisting.Transform = transform;\n\t}\n\n\tpublic bool RemoveWorkingPose( Guid clipId, string target ) =>\n\t\tWorkingPoseOverrides.RemoveAll( x =>\n\t\t\tx.ClipId == clipId\n\t\t\t&& x.Target.Equals( target, StringComparison.OrdinalIgnoreCase ) ) > 0;\n\n\tpublic void ClearWorkingPoses( Guid clipId ) =>\n\t\tWorkingPoseOverrides.RemoveAll( x => x.ClipId == clipId );\n}\n\npublic sealed class TimelineViewState\n{\n\tpublic Guid ClipId { get; set; }\n\tpublic float VisibleStart { get; set; }\n\tpublic float VisibleEnd { get; set; }\n\tpublic float VerticalScroll { get; set; }\n}\n\npublic sealed class CurveViewState\n{\n\tpublic Guid ClipId { get; set; }\n\tpublic Guid SelectedTrackId { get; set; }\n\tpublic CurveEditorMode Mode { get; set; }\n\tpublic TransformCurveChannel VisibleChannels { get; set; }\n\tpublic string Search { get; set; } = \"\";\n\tpublic float TrackScroll { get; set; }\n\tpublic bool HasVerticalRange { get; set; }\n\tpublic float VerticalMinimum { get; set; }\n\tpublic float VerticalMaximum { get; set; } = 2.0f;\n}\n\npublic sealed class WorkingPoseOverride\n{\n\tpublic Guid ClipId { get; set; }\n\tpublic string Target { get; set; } = \"\";\n\tpublic RigControlKind Kind { get; set; }\n\tpublic Transform Transform { get; set; } = Transform.Zero;\n}\n\npublic sealed class GenerationManifest\n{\n\tpublic string GeneratorVersion { get; set; } = \"\";\n\tpublic DateTime GeneratedUtc { get; set; }\n\tpublic string InputHash { get; set; } = \"\";\n\tpublic List<GeneratedFileRecord> Files { get; set; } = [];\n\tpublic List<GenerationDiagnostic> Diagnostics { get; set; } = [];\n}\n\npublic sealed class GeneratedFileRecord\n{\n\tpublic string RelativePath { get; set; } = \"\";\n\tpublic string Sha256 { get; set; } = \"\";\n\tpublic string Kind { get; set; } = \"\";\n}\n\npublic sealed class GenerationDiagnostic\n{\n\tpublic ValidationSeverity Severity { get; set; }\n\tpublic string Code { get; set; } = \"\";\n\tpublic string Message { get; set; } = \"\";\n\tpublic string AssetPath { get; set; } = \"\";\n}\n\npublic static class WeaponAnimationNames\n{\n\tpublic static string DisplayName( WeaponClipRole role ) => role switch\n\t{\n\t\tWeaponClipRole.FireDry => \"Fire Dry\",\n\t\tWeaponClipRole.ReloadEmpty => \"Reload Empty\",\n\t\tWeaponClipRole.GrabStance => \"Grab Stance\",\n\t\tWeaponClipRole.GrabGestureOne => \"Grab Gesture 1\",\n\t\tWeaponClipRole.GrabGestureTwo => \"Grab Gesture 2\",\n\t\tWeaponClipRole.GrabGestureThree => \"Grab Gesture 3\",\n\t\tWeaponClipRole.GrabGestureFour => \"Grab Gesture 4\",\n\t\tWeaponClipRole.ReloadEnter => \"Reload Enter\",\n\t\tWeaponClipRole.FirstShell => \"First Shell\",\n\t\tWeaponClipRole.InsertShell => \"Insert Shell\",\n\t\tWeaponClipRole.ReloadExit => \"Reload Exit\",\n\t\t_ => role.ToString()\n\t};\n\n\tpublic static string SequenceName( WeaponClipRole role ) =>\n\t\tWeaponAnimationDocument.Slugify( DisplayName( role ) );\n\n\tpublic static string SequenceName( WeaponAnimationClip clip ) =>\n\t\tclip.Role == WeaponClipRole.Custom\n\t\t\t? !string.IsNullOrWhiteSpace( clip.GeneratedSequenceName )\n\t\t\t\t? clip.GeneratedSequenceName\n\t\t\t\t: ShortCustomSequenceName(\n\t\t\t\t\tWeaponAnimationDocument.Slugify( clip.Name ),\n\t\t\t\t\tclip.Id )\n\t\t\t: SequenceName( clip.Role );\n\n\tpublic static bool RepairCustomSequenceNames( WeaponAnimationDocument document )\n\t{\n\t\tvar changed = false;\n\t\tvar used = document.Clips\n\t\t\t.Where( clip => clip.Role != WeaponClipRole.Custom )\n\t\t\t.Select( clip => SequenceName( clip.Role ) )\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var clip in document.Clips.Where( clip =>\n\t\t\tclip.Role == WeaponClipRole.Custom ) )\n\t\t{\n\t\t\tvar existing = string.IsNullOrWhiteSpace( clip.GeneratedSequenceName )\n\t\t\t\t? \"\"\n\t\t\t\t: WeaponAnimationDocument.Slugify( clip.GeneratedSequenceName );\n\t\t\tif ( !string.IsNullOrWhiteSpace( existing ) && used.Add( existing ) )\n\t\t\t{\n\t\t\t\tif ( clip.GeneratedSequenceName != existing )\n\t\t\t\t{\n\t\t\t\t\tclip.GeneratedSequenceName = existing;\n\t\t\t\t\tchanged = true;\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar stem = WeaponAnimationDocument.Slugify( clip.Name );\n\t\t\tif ( string.IsNullOrWhiteSpace( stem ) )\n\t\t\t\tstem = \"custom\";\n\t\t\tvar candidate = stem;\n\t\t\tif ( !used.Add( candidate ) )\n\t\t\t{\n\t\t\t\tvar id = clip.Id.ToString( \"N\" );\n\t\t\t\tvar assigned = false;\n\t\t\t\tfor ( var suffixLength = 8;\n\t\t\t\t\tsuffixLength <= id.Length;\n\t\t\t\t\tsuffixLength += 4 )\n\t\t\t\t{\n\t\t\t\t\tcandidate = $\"{stem}_{id[..suffixLength]}\";\n\t\t\t\t\tif ( !used.Add( candidate ) )\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\tassigned = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tfor ( var collision = 2; !assigned; collision++ )\n\t\t\t\t{\n\t\t\t\t\tcandidate = $\"{stem}_{id}_{collision}\";\n\t\t\t\t\tassigned = used.Add( candidate );\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ( clip.GeneratedSequenceName == candidate )\n\t\t\t\tcontinue;\n\t\t\tclip.GeneratedSequenceName = candidate;\n\t\t\tchanged = true;\n\t\t}\n\t\treturn changed;\n\t}\n\n\tprivate static string ShortCustomSequenceName( string stem, Guid id ) =>\n\t\t$\"{stem}_{id:N}\"[..(stem.Length + 9)];\n\n\t/// <summary>\n\t/// Attachment names reserved by the fixed anchor kinds that reach generation. The calibration-only\n\t/// kinds (grip, bore markers) are never exported, so they reserve nothing.\n\t/// </summary>\n\tprivate static readonly string[] ReservedAttachmentNames = [\"muzzle\", \"eject\"];\n\n\tpublic static string AttachmentName( WeaponAnchor anchor ) => anchor.Kind switch\n\t{\n\t\tAnchorKind.Muzzle => \"muzzle\",\n\t\tAnchorKind.Eject => \"eject\",\n\t\t_ => !string.IsNullOrWhiteSpace( anchor.GeneratedAttachmentName )\n\t\t\t? anchor.GeneratedAttachmentName\n\t\t\t: WeaponAnimationDocument.Slugify( anchor.Name )\n\t};\n\n\t/// <summary>\n\t/// Assigns each custom anchor a stable, unique attachment name. Mirrors\n\t/// <see cref=\"RepairCustomSequenceNames\"/>: an existing stored name is kept whenever it is still\n\t/// unique, so generated output stays stable across renames.\n\t/// </summary>\n\tpublic static bool RepairCustomAnchorNames( WeaponAnimationDocument document )\n\t{\n\t\tvar changed = false;\n\t\tvar used = ReservedAttachmentNames.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var anchor in document.Calibration.CustomAnchors() )\n\t\t{\n\t\t\tvar existing = string.IsNullOrWhiteSpace( anchor.GeneratedAttachmentName )\n\t\t\t\t? \"\"\n\t\t\t\t: WeaponAnimationDocument.Slugify( anchor.GeneratedAttachmentName );\n\t\t\tif ( !string.IsNullOrWhiteSpace( existing ) && used.Add( existing ) )\n\t\t\t{\n\t\t\t\tif ( anchor.GeneratedAttachmentName != existing )\n\t\t\t\t{\n\t\t\t\t\tanchor.GeneratedAttachmentName = existing;\n\t\t\t\t\tchanged = true;\n\t\t\t\t}\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar stem = WeaponAnimationDocument.Slugify( anchor.Name );\n\t\t\tif ( string.IsNullOrWhiteSpace( stem ) )\n\t\t\t\tstem = \"anchor\";\n\t\t\tvar candidate = stem;\n\t\t\tif ( !used.Add( candidate ) )\n\t\t\t{\n\t\t\t\tvar id = anchor.Id.ToString( \"N\" );\n\t\t\t\tvar assigned = false;\n\t\t\t\tfor ( var suffixLength = 8; suffixLength <= id.Length; suffixLength += 4 )\n\t\t\t\t{\n\t\t\t\t\tcandidate = $\"{stem}_{id[..suffixLength]}\";\n\t\t\t\t\tif ( !used.Add( candidate ) )\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\tassigned = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tfor ( var collision = 2; !assigned; collision++ )\n\t\t\t\t{\n\t\t\t\t\tcandidate = $\"{stem}_{id}_{collision}\";\n\t\t\t\t\tassigned = used.Add( candidate );\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ( anchor.GeneratedAttachmentName == candidate )\n\t\t\t\tcontinue;\n\t\t\tanchor.GeneratedAttachmentName = candidate;\n\t\t\tchanged = true;\n\t\t}\n\t\treturn changed;\n\t}\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Services/PreviewHostBuilder.cs",
            "FileName": "PreviewHostBuilder.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.IO;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic sealed class PreviewHostResult\n{\n\tpublic bool Success { get; init; }\n\tpublic string Message { get; init; } = \"\";\n\tpublic string ModelPath { get; init; } = \"\";\n}\n\npublic static class PreviewHostBuilder\n{\n\tpublic static PreviewHostResult Build( WeaponAnimationDocument document )\n\t{\n\t\ttry\n\t\t{\n\t\t\tdocument.Rig.AuditIssues.RemoveAll( x =>\n\t\t\t\tx.Code is \"arm_bone_collision\" or \"bind_pose_mismatch\" );\n\t\t\tvar collisions = HostSkeletonBuilder.FindArmBoneCollisions( document );\n\t\t\tforeach ( var collision in collisions )\n\t\t\t{\n\t\t\t\tdocument.Rig.AuditIssues.Add( new RigAuditIssue\n\t\t\t\t{\n\t\t\t\t\tCode = \"arm_bone_collision\",\n\t\t\t\t\tMessage = $\"Retained weapon bone '{collision}' conflicts with the Facepunch arm skeleton.\",\n\t\t\t\t\tSeverity = ValidationSeverity.Error,\n\t\t\t\t\tBoneName = collision\n\t\t\t\t} );\n\t\t\t}\n\n\t\t\tvar parityIssues = HostSkeletonBuilder.ValidateBindParity( document );\n\t\t\tforeach ( var mismatch in parityIssues )\n\t\t\t{\n\t\t\t\tdocument.Rig.AuditIssues.Add( new RigAuditIssue\n\t\t\t\t{\n\t\t\t\t\tCode = \"bind_pose_mismatch\",\n\t\t\t\t\tMessage =\n\t\t\t\t\t\t$\"Bind pose mismatch for '{mismatch.BoneName}': \"\n\t\t\t\t\t\t+ $\"position {mismatch.PositionDelta:0.######}, \"\n\t\t\t\t\t\t+ $\"rotation {mismatch.RotationDelta:0.######}, \"\n\t\t\t\t\t\t+ $\"scale {mismatch.ScaleDelta:0.######}.\",\n\t\t\t\t\tSeverity = ValidationSeverity.Error,\n\t\t\t\t\tBoneName = mismatch.BoneName\n\t\t\t\t} );\n\t\t\t}\n\n\t\t\tif ( collisions.Count > 0 || parityIssues.Count > 0 )\n\t\t\t{\n\t\t\t\tdocument.Source.PreviewHostCompiled = false;\n\t\t\t\tvar blockedDetail = collisions.Count > 0\n\t\t\t\t\t? $\"Retained weapon bones collide with Facepunch bones: {string.Join( \", \", collisions )}.\"\n\t\t\t\t\t: document.Rig.AuditIssues.First( x => x.Code == \"bind_pose_mismatch\" ).Message;\n\t\t\t\tLog.Warning( $\"[Weapon Animator] preview host blocked: {blockedDetail}\" );\n\t\t\t\treturn new PreviewHostResult\n\t\t\t\t{\n\t\t\t\t\tSuccess = false,\n\t\t\t\t\tMessage = blockedDetail\n\t\t\t\t};\n\t\t\t}\n\n\t\t\tvar cache = WeaponSourceImporter.GetPreviewCacheRoot( document.DocumentId );\n\t\t\tDirectory.CreateDirectory( cache );\n\t\t\tvar skeleton = HostSkeletonBuilder.Build( document );\n\t\t\tvar dmxAbsolute = Path.Combine( cache, \"animation_host_reference.dmx\" );\n\t\t\tvar vmdlAbsolute = Path.Combine( cache, \"animation_host_preview.vmdl\" );\n\t\t\tvar dmxRelative = WeaponSourceImporter.RelativeAssetPath( dmxAbsolute );\n\n\t\t\tAtomicFile.WriteAllText( dmxAbsolute, DmxWriter.WriteReference( skeleton ) );\n\t\t\tAtomicFile.WriteAllText(\n\t\t\t\tvmdlAbsolute,\n\t\t\t\tModelDocWriter.WriteHost(\n\t\t\t\t\tdmxRelative,\n\t\t\t\t\t[],\n\t\t\t\t\t\"\",\n\t\t\t\t\tskeleton.Bones.Select( bone => bone.Name ) ) );\n\n\t\t\tvar asset = AssetSystem.RegisterFile( vmdlAbsolute );\n\t\t\tvar compileReturned = asset?.Compile( true ) == true;\n\t\t\tvar compiled = asset is not null\n\t\t\t\t&& asset.IsCompiled\n\t\t\t\t&& asset.IsCompiledAndUpToDate;\n\t\t\tvar model = compiled ? asset!.LoadResource<Model>() : null;\n\t\t\tvar success = compiled && model is not null && !model.IsError && model.BoneCount == skeleton.Bones.Count;\n\t\t\tvar detail = DescribeResult(\n\t\t\t\tasset,\n\t\t\t\tcompileReturned,\n\t\t\t\tcompiled,\n\t\t\t\tmodel,\n\t\t\t\tskeleton.Bones.Count );\n\n\t\t\tdocument.Source.PreviewHostPath = asset?.Path ?? \"\";\n\t\t\tdocument.Source.PreviewHostCompiled = success;\n\t\t\tif ( !success )\n\t\t\t\tLog.Warning( $\"[Weapon Animator] preview host verification failed: {detail}\" );\n\t\t\treturn new PreviewHostResult\n\t\t\t{\n\t\t\t\tSuccess = success,\n\t\t\t\tModelPath = asset?.Path ?? \"\",\n\t\t\t\tMessage = success\n\t\t\t\t\t? $\"Preview host compiled with {skeleton.Bones.Count} bones.\"\n\t\t\t\t\t: $\"Preview host verification failed: {detail}\"\n\t\t\t};\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tdocument.Source.PreviewHostCompiled = false;\n\t\t\tLog.Error( $\"[Weapon Animator] preview host build failed: {ex}\" );\n\t\t\treturn new PreviewHostResult\n\t\t\t{\n\t\t\t\tSuccess = false,\n\t\t\t\tMessage = ex.Message\n\t\t\t};\n\t\t}\n\t}\n\n\tprivate static string DescribeResult(\n\t\tAsset? asset,\n\t\tbool compileReturned,\n\t\tbool compiled,\n\t\tModel? model,\n\t\tint expectedBones )\n\t{\n\t\tif ( asset is null )\n\t\t\treturn \"the generated VMDL was not registered with the Asset System.\";\n\t\tif ( !compiled )\n\t\t{\n\t\t\treturn $\"compile returned {compileReturned}, IsCompiled={asset.IsCompiled}, \"\n\t\t\t\t+ $\"IsCompiledAndUpToDate={asset.IsCompiledAndUpToDate}.\";\n\t\t}\n\t\tif ( model is null )\n\t\t\treturn \"the compiled resource could not be loaded as a model.\";\n\t\tif ( model.IsError )\n\t\t\treturn \"the compiled resource reloaded as the error model.\";\n\t\tif ( model.BoneCount != expectedBones )\n\t\t\treturn $\"expected {expectedBones} bones but the compiled model exposes {model.BoneCount}.\";\n\t\treturn \"the compiled resource did not pass validation.\";\n\t}\n}\n\npublic static class AtomicFile\n{\n\tpublic static void WriteAllText( string path, string content )\n\t{\n\t\tvar directory = Path.GetDirectoryName( path );\n\t\tif ( !string.IsNullOrWhiteSpace( directory ) )\n\t\t\tDirectory.CreateDirectory( directory );\n\n\t\tvar temporary = path + $\".tmp.{Guid.NewGuid():N}\";\n\t\tFile.WriteAllText( temporary, content, new System.Text.UTF8Encoding( false ) );\n\t\tFile.Move( temporary, path, true );\n\t}\n}\n"
        },
        {
            "Ident": "sonac.sbox-animator",
            "Path": "Editor/Widgets/AnimationWorkspaceRedesign.cs",
            "FileName": "AnimationWorkspaceRedesign.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 337289,
            "Code": "#nullable enable annotations\n\nusing System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.Linq;\nusing Editor;\nusing Sandbox;\n\nnamespace SboxWeaponAnimator.Editor;\n\npublic sealed class RigBrowserPanel : Widget\n{\n\tprivate readonly WeaponAnimatorController _controller;\n\tprivate readonly ScrollArea _scroll;\n\tprivate readonly Widget _canvas;\n\tprivate readonly LineEdit _search;\n\tprivate readonly Dictionary<string, WeaponAnimatorButton> _itemButtons =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate readonly Dictionary<string, bool> _weaponItems =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate readonly Dictionary<string, bool> _expanded = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t[\"Controls\"] = true,\n\t\t[\"Weapon\"] = true,\n\t\t[\"Right arm\"] = true,\n\t\t[\"Left arm\"] = true,\n\t\t[\"Fingers\"] = false,\n\t\t[\"Advanced\"] = false\n\t};\n\tprivate string _filter = \"\";\n\tprivate bool _rebuilding;\n\tprivate bool _rebuildPending;\n\tprivate string _structureSignature = \"\";\n\n\tpublic RigBrowserPanel( WeaponAnimatorController controller, Widget? parent = null ) : base( parent )\n\t{\n\t\t_controller = controller;\n\t\tLayout = Layout.Column();\n\t\tLayout.Margin = new Sandbox.UI.Margin( 8 );\n\t\tLayout.Spacing = 6;\n\n\t\t_search = new LineEdit( this )\n\t\t{\n\t\t\tPlaceholderText = \"Search controls and bones\u2026\",\n\t\t\tFixedHeight = 28\n\t\t};\n\t\t_search.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t_search.TextChanged += text =>\n\t\t{\n\t\t\t_filter = text.Trim();\n\t\t\tRebuild();\n\t\t};\n\t\tLayout.Add( _search );\n\n\t\t_scroll = new ScrollArea( this );\n\t\t_canvas = new Widget( _scroll );\n\t\t_canvas.Layout = Layout.Column();\n\t\t_canvas.Layout.Margin = WeaponAnimatorTheme.ScrollCanvasMargin();\n\t\t_canvas.Layout.Spacing = 2;\n\t\t_scroll.Canvas = _canvas;\n\t\tLayout.Add( _scroll, 1 );\n\n\t\t_controller.DocumentChanged += RefreshDocument;\n\t\t_controller.SelectionChanged += RefreshSelection;\n\t\tRebuild();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t_controller.DocumentChanged -= RefreshDocument;\n\t\t_controller.SelectionChanged -= RefreshSelection;\n\t\tbase.OnDestroyed();\n\t}\n\n\tprivate void RefreshDocument()\n\t{\n\t\tvar skeleton = HostSkeletonBuilder.BuildCached( _controller.Document );\n\t\tif ( _structureSignature != StructureSignature( skeleton ) )\n\t\t{\n\t\t\tRebuild();\n\t\t\treturn;\n\t\t}\n\n\t\tUpdateControlLabels();\n\t\tRefreshSelection();\n\t}\n\n\tprivate void Rebuild()\n\t{\n\t\tif ( _rebuilding )\n\t\t{\n\t\t\t_rebuildPending = true;\n\t\t\treturn;\n\t\t}\n\n\t\t_rebuilding = true;\n\t\ttry\n\t\t{\n\t\t\tdo\n\t\t\t{\n\t\t\t\t_rebuildPending = false;\n\t\t\t\t_canvas.Layout.Clear( true );\n\t\t\t\t_itemButtons.Clear();\n\t\t\t\t_weaponItems.Clear();\n\t\t\t\tvar skeleton = HostSkeletonBuilder.BuildCached( _controller.Document );\n\t\t\t\t_structureSignature = StructureSignature( skeleton );\n\t\t\t\tvar groups = GroupBones( skeleton );\n\t\t\t\tAddControlGroup();\n\t\t\t\tforeach ( var name in new[] { \"Weapon\", \"Right arm\", \"Left arm\", \"Fingers\", \"Advanced\" } )\n\t\t\t\t\tAddBoneGroup( name, groups.GetValueOrDefault( name ) ?? [], skeleton );\n\t\t\t\t_canvas.Layout.AddStretchCell();\n\t\t\t}\n\t\t\twhile ( _rebuildPending );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\t_rebuilding = false;\n\t\t}\n\n\t\tRefreshSelection( reveal: false );\n\t\tUpdateControlLabels();\n\t}\n\n\tprivate void AddControlGroup()\n\t{\n\t\tvar controls = new[]\n\t\t{\n\t\t\t(\"@primary_hand\", $\"Primary hand \u00b7 {BoundText( _controller.Document.Binding.PrimaryHand )}\"),\n\t\t\t(\"@support_hand\", $\"Support hand \u00b7 {BoundText( _controller.Document.Binding.SupportHand )}\"),\n\t\t\t(\"@primary_elbow\", \"Primary elbow\"),\n\t\t\t(\"@support_elbow\", \"Support elbow\")\n\t\t};\n\t\tvar visible = controls.Where( x => Matches( x.Item2 ) ).ToArray();\n\t\tvar body = AddGroup( \"Controls\", visible.Length );\n\n\t\tforeach ( var control in visible )\n\t\t{\n\t\t\tvar button = new WeaponAnimatorButton( control.Item2, body )\n\t\t\t{\n\t\t\t\tClicked = () => _controller.SelectControl( control.Item1 ),\n\t\t\t\tTint = _controller.Document.Workspace.SelectedControl == control.Item1\n\t\t\t\t\t? WeaponAnimatorTheme.Cyan * 0.48f\n\t\t\t\t\t: WeaponAnimatorTheme.Surface\n\t\t\t};\n\t\t\tbody.Layout.Add( button );\n\t\t\t_itemButtons[control.Item1] = button;\n\t\t\t_weaponItems[control.Item1] = false;\n\t\t}\n\t}\n\n\tprivate void AddBoneGroup(\n\t\tstring name,\n\t\tIReadOnlyList<HostBone> bones,\n\t\tHostSkeleton skeleton )\n\t{\n\t\tvar visible = bones.Where( x => Matches( x.Name ) ).ToArray();\n\t\tvar body = AddGroup( name, visible.Length );\n\n\t\tforeach ( var bone in visible.OrderBy( x => x.Index ) )\n\t\t{\n\t\t\tvar depth = HierarchyDepth( bone, skeleton );\n\t\t\tvar row = RigAuditPanel.Row( body );\n\t\t\trow.FixedHeight = 28;\n\t\t\tvar indentation = new Widget( row )\n\t\t\t{\n\t\t\t\tFixedWidth = Math.Min( depth, 8 ) * 12\n\t\t\t};\n\t\t\trow.Layout.Add( indentation );\n\t\t\tvar button = new WeaponAnimatorButton(\n\t\t\t\tbone.Name,\n\t\t\t\trow )\n\t\t\t{\n\t\t\t\tClicked = () => _controller.SelectBone( bone.Name ),\n\t\t\t\tTint = _controller.Document.Workspace.SelectedBone == bone.Name\n\t\t\t\t\t? (bone.IsWeaponBone ? WeaponAnimatorTheme.Amber : WeaponAnimatorTheme.Cyan) * 0.48f\n\t\t\t\t\t: WeaponAnimatorTheme.Surface\n\t\t\t};\n\t\t\trow.Layout.Add( button, 1 );\n\t\t\tbody.Layout.Add( row );\n\t\t\t_itemButtons[bone.Name] = button;\n\t\t\t_weaponItems[bone.Name] = bone.IsWeaponBone;\n\t\t}\n\t}\n\n\tprivate Widget AddGroup( string name, int count )\n\t{\n\t\tvar selectedInGroup = SelectedGroup() == name;\n\t\tif ( selectedInGroup )\n\t\t\t_expanded[name] = true;\n\t\tvar body = new Widget( _canvas )\n\t\t{\n\t\t\tLayout = Layout.Column()\n\t\t};\n\t\tbody.Layout.Margin = 0;\n\t\tbody.Layout.Spacing = 2;\n\t\tbody.Visible = GroupBodyVisible( name );\n\t\tvar header = new WeaponAnimatorButton(\n\t\t\tGroupHeaderText( name, count ),\n\t\t\t_canvas )\n\t\t{\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\theader.Clicked = () =>\n\t\t{\n\t\t\t_expanded[name] = !_expanded[name];\n\t\t\tbody.Visible = GroupBodyVisible( name );\n\t\t\theader.Text = GroupHeaderText( name, count );\n\t\t\tbody.UpdateGeometry();\n\t\t};\n\t\theader.FixedHeight = 25;\n\t\t_canvas.Layout.Add( header );\n\t\t_canvas.Layout.Add( body );\n\t\treturn body;\n\t}\n\n\tprivate bool GroupBodyVisible( string name ) =>\n\t\t_expanded[name] || !string.IsNullOrWhiteSpace( _filter );\n\n\tprivate string GroupHeaderText( string name, int count ) =>\n\t\t$\"{(GroupBodyVisible( name ) ? \"\u25be\" : \"\u25b8\")}  {name.ToUpperInvariant()}  {count}\";\n\n\tprivate string SelectedGroup()\n\t{\n\t\tif ( !string.IsNullOrWhiteSpace( _controller.Document.Workspace.SelectedControl ) )\n\t\t\treturn \"Controls\";\n\t\tvar selected = _controller.Document.Workspace.SelectedBone;\n\t\tif ( string.IsNullOrWhiteSpace( selected ) )\n\t\t\treturn \"\";\n\t\treturn GroupName( HostSkeletonBuilder.BuildCached( _controller.Document ).ByName.GetValueOrDefault( selected ) );\n\t}\n\n\tprivate Dictionary<string, List<HostBone>> GroupBones( HostSkeleton skeleton )\n\t{\n\t\tvar groups = new Dictionary<string, List<HostBone>>( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var bone in skeleton.Bones )\n\t\t{\n\t\t\tvar group = GroupName( bone );\n\t\t\tif ( !groups.TryGetValue( group, out var list ) )\n\t\t\t\tgroups[group] = list = [];\n\t\t\tlist.Add( bone );\n\t\t}\n\t\treturn groups;\n\t}\n\n\tinternal static string GroupName( HostBone? bone )\n\t{\n\t\tif ( bone is null )\n\t\t\treturn \"Advanced\";\n\t\tif ( bone.IsWeaponBone )\n\t\t\treturn \"Weapon\";\n\t\tif ( bone.Name.Contains( \"finger\", StringComparison.OrdinalIgnoreCase )\n\t\t\t|| bone.Name.Contains( \"thumb\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn \"Fingers\";\n\t\tif ( bone.Name.EndsWith( \"_R\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn \"Right arm\";\n\t\tif ( bone.Name.EndsWith( \"_L\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn \"Left arm\";\n\t\treturn \"Advanced\";\n\t}\n\n\tprivate static int HierarchyDepth( HostBone bone, HostSkeleton skeleton )\n\t{\n\t\tvar depth = 0;\n\t\tvar parent = bone.ParentName;\n\t\twhile ( !string.IsNullOrWhiteSpace( parent )\n\t\t\t&& skeleton.ByName.TryGetValue( parent, out var parentBone )\n\t\t\t&& depth < 16 )\n\t\t{\n\t\t\tdepth++;\n\t\t\tparent = parentBone.ParentName;\n\t\t}\n\t\treturn depth;\n\t}\n\n\tprivate bool Matches( string text ) =>\n\t\tstring.IsNullOrWhiteSpace( _filter )\n\t\t|| text.Contains( _filter, StringComparison.OrdinalIgnoreCase );\n\n\tprivate void RefreshSelection()\n\t{\n\t\tRefreshSelection( reveal: true );\n\t}\n\n\tprivate void RefreshSelection( bool reveal )\n\t{\n\t\tif ( _rebuilding )\n\t\t\treturn;\n\n\t\tvar selected = SelectedItem();\n\t\tvar group = SelectedGroup();\n\t\tif ( !string.IsNullOrWhiteSpace( group )\n\t\t\t&& _expanded.TryGetValue( group, out var expanded )\n\t\t\t&& !expanded )\n\t\t{\n\t\t\t_expanded[group] = true;\n\t\t\tRebuild();\n\t\t\treturn;\n\t\t}\n\n\t\tforeach ( var item in _itemButtons )\n\t\t{\n\t\t\tvar isSelected = item.Key.Equals(\n\t\t\t\tselected,\n\t\t\t\tStringComparison.OrdinalIgnoreCase );\n\t\t\tvar accent = _weaponItems.GetValueOrDefault( item.Key )\n\t\t\t\t? WeaponAnimatorTheme.Amber\n\t\t\t\t: WeaponAnimatorTheme.Cyan;\n\t\t\titem.Value.Tint = isSelected\n\t\t\t\t? accent * 0.48f\n\t\t\t\t: WeaponAnimatorTheme.Surface;\n\t\t}\n\n\t\tif ( reveal\n\t\t\t&& !string.IsNullOrWhiteSpace( selected )\n\t\t\t&& _itemButtons.TryGetValue( selected, out var button ) )\n\t\t\tRevealIfNeeded( button );\n\t}\n\n\tprivate void RevealIfNeeded( Widget button )\n\t{\n\t\tif ( button.Height <= 0 || _scroll.Height <= 0 )\n\t\t\treturn;\n\n\t\tvar viewportTop = _scroll.ScreenPosition.y;\n\t\tvar viewportBottom = viewportTop + _scroll.Height;\n\t\tvar itemTop = button.ScreenPosition.y;\n\t\tvar itemBottom = itemTop + button.Height;\n\t\tif ( itemTop < viewportTop )\n\t\t{\n\t\t\t_scroll.VerticalScrollbar.Value -=\n\t\t\t\t(viewportTop - itemTop).CeilToInt();\n\t\t}\n\t\telse if ( itemBottom > viewportBottom )\n\t\t{\n\t\t\t_scroll.VerticalScrollbar.Value +=\n\t\t\t\t(itemBottom - viewportBottom).CeilToInt();\n\t\t}\n\t}\n\n\tprivate string SelectedItem() =>\n\t\t!string.IsNullOrWhiteSpace( _controller.Document.Workspace.SelectedControl )\n\t\t\t? _controller.Document.Workspace.SelectedControl\n\t\t\t: _controller.Document.Workspace.SelectedBone;\n\n\tprivate void UpdateControlLabels()\n\t{\n\t\tvar labels = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\t[\"@primary_hand\"] =\n\t\t\t\t$\"Primary hand \u00b7 {BoundText( _controller.Document.Binding.PrimaryHand )}\",\n\t\t\t[\"@support_hand\"] =\n\t\t\t\t$\"Support hand \u00b7 {BoundText( _controller.Document.Binding.SupportHand )}\",\n\t\t\t[\"@primary_elbow\"] = \"Primary elbow\",\n\t\t\t[\"@support_elbow\"] = \"Support elbow\"\n\t\t};\n\t\tforeach ( var item in labels )\n\t\t{\n\t\t\tif ( _itemButtons.TryGetValue( item.Key, out var button ) )\n\t\t\t\tbutton.Text = item.Value;\n\t\t}\n\n\t\tforeach ( var bone in HostSkeletonBuilder.BuildCached( _controller.Document )\n\t\t\t.Bones.Where( x => x.IsWeaponBone ) )\n\t\t{\n\t\t\tif ( !_itemButtons.TryGetValue( bone.Name, out var button ) )\n\t\t\t\tcontinue;\n\t\t\tbutton.Icon = _controller.GetVisibilityPart( bone.Name ) is null\n\t\t\t\t? \"\"\n\t\t\t\t: \"visibility\";\n\t\t}\n\t}\n\n\tinternal static string StructureSignature( HostSkeleton skeleton ) =>\n\t\tstring.Join(\n\t\t\t\"|\",\n\t\t\tskeleton.Bones.Select( bone =>\n\t\t\t\t$\"{bone.Name}>{bone.ParentName}:{bone.IsWeaponBone}\" ) );\n\n\tprivate static string BoundText( RigTarget target ) => target.IsBound ? \"bound\" : \"unbound\";\n}\n\npublic sealed partial class SelectedControlInspectorPanel : Widget\n{\n\tprivate readonly WeaponAnimatorController _controller;\n\tprivate readonly Label _type;\n\tprivate readonly Label _name;\n\tprivate readonly Label _details;\n\tprivate readonly Label _keyState;\n\tprivate readonly Widget _identity;\n\tprivate readonly Widget _identitySpine;\n\tprivate readonly Widget _transform;\n\tprivate Widget _checklist = null!;\n\tprivate readonly List<Action> _refreshers = [];\n\tprivate bool _checklistExpanded;\n\tprivate bool _rebuildingTransformFields;\n\tprivate bool _refreshingTransformFields;\n\tprivate bool _lastLocalGizmos;\n\tprivate int _transformFieldGeneration;\n\n\tpublic event Action<string, ValidationSeverity>? StatusChanged;\n\n\tpublic SelectedControlInspectorPanel(\n\t\tWeaponAnimatorController controller,\n\t\tWidget? parent = null ) : base( parent )\n\t{\n\t\t_controller = controller;\n\t\tLayout = Layout.Column();\n\t\tLayout.Margin = 0;\n\t\tLayout.Spacing = 0;\n\n\t\t_identity = new Widget( this );\n\t\t_identity.Layout = Layout.Row();\n\t\t_identity.Layout.Margin = 0;\n\t\t_identity.Layout.Spacing = 0;\n\t\t_identity.SetStyles(\n\t\t\t\"background-color: rgb(25,28,32); border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;\" );\n\t\t_identitySpine = new Widget( _identity ) { FixedWidth = 3 };\n\t\t_identitySpine.SetStyles(\n\t\t\t$\"background-color: {WeaponAnimatorTheme.Cyan.Hex}; border: none; border-radius: 0px;\" );\n\t\t_identity.Layout.Add( _identitySpine );\n\t\tvar identityContent = new Widget( _identity );\n\t\tidentityContent.Layout = Layout.Column();\n\t\tidentityContent.Layout.Margin = new Sandbox.UI.Margin( 12, 11, 14, 11 );\n\t\tidentityContent.Layout.Spacing = 3;\n\t\t_type = WeaponAnimatorTheme.SectionLabel( \"NO SELECTION\", identityContent, WeaponAnimatorTheme.Cyan );\n\t\t_name = WeaponAnimatorTheme.Label( \"Select a control or bone\", identityContent );\n\t\t_name.SetStyles(\n\t\t\t\"background-color: transparent; border: none; padding: 0px;\" +\n\t\t\t$\"font-size: 17px; font-weight: 500; color: {WeaponAnimatorTheme.Text.Hex};\" );\n\t\t_details = WeaponAnimatorTheme.Label( \"Choose an item in the rig browser.\", identityContent, true );\n\t\t_keyState = WeaponAnimatorTheme.Label( \"No key\", identityContent, true );\n\t\tidentityContent.Layout.Add( _type );\n\t\tidentityContent.Layout.Add( _name );\n\t\tidentityContent.Layout.Add( _details );\n\t\tidentityContent.Layout.Add( _keyState );\n\t\t_identity.Layout.Add( identityContent, 1 );\n\t\tLayout.Add( _identity );\n\n\t\tLayout.Add( BuildChecklist() );\n\t\t_transform = new Widget( this );\n\t\t_transform.Layout = Layout.Column();\n\t\t_transform.Layout.Margin = new Sandbox.UI.Margin( 10, 8, 10, 8 );\n\t\t_transform.Layout.Spacing = 6;\n\t\tLayout.Add( _transform );\n\n\t\tvar tools = new AnimationInspectorPanel( controller, this, controlToolsOnly: true );\n\t\ttools.StatusChanged += ( message, severity ) => StatusChanged?.Invoke( message, severity );\n\t\tLayout.Add( tools, 1 );\n\t\t_controller.SelectionChanged += RebuildTransform;\n\t\t_controller.DocumentChanged += Refresh;\n\t\t_controller.PoseChanged += RefreshPose;\n\t\t_controller.TimelineChanged += Refresh;\n\t\tRebuildTransform();\n\t}\n\n\tpublic override void OnDestroyed()\n\t{\n\t\t_controller.SelectionChanged -= RebuildTransform;\n\t\t_controller.DocumentChanged -= Refresh;\n\t\t_controller.PoseChanged -= RefreshPose;\n\t\t_controller.TimelineChanged -= Refresh;\n\t\tbase.OnDestroyed();\n\t}\n\n\tprivate Widget BuildChecklist()\n\t{\n\t\t_checklist = new Widget( this );\n\t\t_checklist.Layout = Layout.Column();\n\t\t_checklist.Layout.Margin = new Sandbox.UI.Margin( 10, 7, 10, 7 );\n\t\t_checklist.Layout.Spacing = 4;\n\t\t_checklist.SetStyles(\n\t\t\t\"background-color: rgb(22,25,28); border: none; border-bottom: 1px solid rgba(255,255,255,0.06);\" );\n\t\tRebuildChecklist();\n\t\treturn _checklist;\n\t}\n\n\tprivate void RebuildChecklist()\n\t{\n\t\tif ( _checklist is null || !_checklist.IsValid() )\n\t\t\treturn;\n\t\t_checklist.Layout.Clear( true );\n\t\t_checklist.Visible = !_controller.Document.Binding.ChecklistDismissed;\n\t\tif ( !_checklist.Visible )\n\t\t\treturn;\n\n\t\tvar states = ChecklistStates();\n\t\tvar complete = states.Count( x => x.Complete );\n\t\tvar next = states.FirstOrDefault( x => !x.Complete );\n\t\tvar header = RigAuditPanel.Row( _checklist );\n\t\tvar toggle = new WeaponAnimatorButton(\n\t\t\t$\"{complete}/5  Grip setup{(next.Label is null ? \" \u00b7 Complete\" : $\" \u00b7 Next: {next.Label}\")}\",\n\t\t\t\"checklist\",\n\t\t\theader )\n\t\t{\n\t\t\tClicked = () =>\n\t\t\t{\n\t\t\t\t_checklistExpanded = !_checklistExpanded;\n\t\t\t\tRebuildChecklist();\n\t\t\t},\n\t\t\tTint = WeaponAnimatorTheme.Surface\n\t\t};\n\t\theader.Layout.Add( toggle, 1 );\n\t\tvar dismiss = new WeaponAnimatorButton( \"\", \"close\", header )\n\t\t{\n\t\t\tClicked = () => _controller.Mutate(\n\t\t\t\t\"Dismiss binding checklist\",\n\t\t\t\tdocument => document.Binding.ChecklistDismissed = true ),\n\t\t\tTint = WeaponAnimatorTheme.Surface,\n\t\t\tToolTip = \"Dismiss setup guide\"\n\t\t};\n\t\tdismiss.FixedWidth = 32;\n\t\theader.Layout.Add( dismiss );\n\t\t_checklist.Layout.Add( header );\n\t\tif ( !_checklistExpanded )\n\t\t\treturn;\n\n\t\tforeach ( var state in states )\n\t\t{\n\t\t\tvar captured = state;\n\t\t\t_checklist.Layout.Add( new WeaponAnimatorButton(\n\t\t\t\t$\"{(captured.Complete ? \"\u2713\" : \"\u25cb\")}  {captured.Label}\",\n\t\t\t\t_checklist )\n\t\t\t{\n\t\t\t\tClicked = captured.Select,\n\t\t\t\tTint = captured.Complete\n\t\t\t\t\t? WeaponAnimatorTheme.Green * 0.22f\n\t\t\t\t\t: WeaponAnimatorTheme.Surface\n\t\t\t} );\n\t\t}\n\t}\n\n\tprivate List<(string? Label, bool Complete, Action Select)> ChecklistStates()\n\t{\n\t\tvar document = _controller.Document;\n\t\tvar clip = document.GetSelectedClip();\n\t\tvar hasElbow = clip?.Tracks.Any( x =>\n\t\t\t(x.Target is \"@primary_elbow\" or \"@support_elbow\") && x.Keys.Count > 0 ) == true;\n\t\tvar hasFinger = clip?.Tracks.Any( x =>\n\t\t\tx.Target.Contains( \"finger\", StringComparison.OrdinalIgnoreCase ) && x.Keys.Count > 0 ) == true;\n\t\treturn\n\t\t[\n\t\t\t(\"Bind primary hand\", document.Binding.PrimaryHand.IsBound, () => _controller.SelectControl( \"@primary_hand\" )),\n\t\t\t(\"Bind support hand\",\n\t\t\t\tdocument.Binding.Configuration == GripConfiguration.OneHanded\n\t\t\t\t\t|| document.Binding.SupportHand.IsBound,\n\t\t\t\t() => _controller.SelectControl( \"@support_hand\" )),\n\t\t\t(\"Adjust elbow poles\", hasElbow, () => _controller.SelectControl( \"@primary_elbow\" )),\n\t\t\t(\"Pose fingers\", hasFinger, () => _controller.SelectBone( \"finger_index_0_R\" )),\n\t\t\t(\"Save default grip pose\",\n\t\t\t\tdocument.Binding.GripPoses.Count > 0,\n\t\t\t\t() => _controller.SelectControl( \"@primary_hand\" ))\n\t\t];\n\t}\n\n\tprivate void RebuildTransform()\n\t{\n\t\t_rebuildingTransformFields = true;\n\t\t_lastLocalGizmos = _controller.Document.Workspace.LocalGizmos;\n\t\t_transformFieldGeneration++;\n\t\ttry\n\t\t{\n\t\t\tRebuildChecklist();\n\t\t\t_transform.Layout.Clear( true );\n\t\t\t_refreshers.Clear();\n\t\t\tvar context = SelectionTransformContext.Resolve( _controller );\n\t\t\tif ( context is null )\n\t\t\t{\n\t\t\t\tRefreshIdentity( null );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tRefreshIdentity( context );\n\t\t\tvar mode = RigAuditPanel.Row( _transform );\n\t\t\tmode.Layout.Add( WeaponAnimatorTheme.SectionLabel(\n\t\t\t\t\"TRANSFORM\",\n\t\t\t\tmode,\n\t\t\t\tcontext.Kind == RigControlKind.Weapon\n\t\t\t\t\t? WeaponAnimatorTheme.Amber\n\t\t\t\t\t: WeaponAnimatorTheme.Cyan ) );\n\t\t\tmode.Layout.AddStretchCell();\n\t\t\tmode.Layout.Add( ToggleAutoKey( mode ) );\n\t\t\t_transform.Layout.Add( mode );\n\n\t\t\tvar space = context.LocalSpace ? \"Local\" : \"World\";\n\t\t\tAddVectorRow( $\"{space} Position\", 0.05f, context, TransformPart.Position );\n\t\t\tAddVectorRow( $\"{space} Rotation\", 0.5f, context, TransformPart.Rotation );\n\t\t\tAddVectorRow( $\"{space} Scale\", 0.005f, context, TransformPart.Scale );\n\n\t\t\tvar actions = RigAuditPanel.Row( _transform );\n\t\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\t\"Key pose\",\n\t\t\t\t\"diamond\",\n\t\t\t\t() =>\n\t\t\t\t{\n\t\t\t\t\tvar current = SelectionTransformContext.Resolve( _controller );\n\t\t\t\t\tif ( current is not null )\n\t\t\t\t\t\t_controller.CommitWorkingPose(\n\t\t\t\t\t\t\tcurrent.Target,\n\t\t\t\t\t\t\tcurrent.Kind,\n\t\t\t\t\t\t\tcurrent.LocalTransform );\n\t\t\t\t},\n\t\t\t\tactions,\n\t\t\t\ttrue ) );\n\t\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\t\"Revert\",\n\t\t\t\t\"restart_alt\",\n\t\t\t\t() =>\n\t\t\t\t{\n\t\t\t\t\tvar current = SelectionTransformContext.Resolve( _controller );\n\t\t\t\t\tif ( current is not null )\n\t\t\t\t\t\t_controller.DiscardWorkingPose( current.Target );\n\t\t\t\t},\n\t\t\t\tactions ) );\n\t\t\tif ( !context.Target.StartsWith( \"@\", StringComparison.Ordinal ) )\n\t\t\t{\n\t\t\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\t\t\"Reset bind\",\n\t\t\t\t\t\"settings_backup_restore\",\n\t\t\t\t\t() =>\n\t\t\t\t\t{\n\t\t\t\t\t\tvar current = SelectionTransformContext.Resolve( _controller );\n\t\t\t\t\t\tvar skeleton = HostSkeletonBuilder.BuildCached( _controller.Document );\n\t\t\t\t\t\tif ( current is null\n\t\t\t\t\t\t\t|| !skeleton.ByName.TryGetValue( current.Target, out var bone ) )\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t_controller.ApplyTransformEdit(\n\t\t\t\t\t\t\tcurrent.Target,\n\t\t\t\t\t\t\tcurrent.Kind,\n\t\t\t\t\t\t\tskeleton.GetBindLocal( bone ) );\n\t\t\t\t\t},\n\t\t\t\t\tactions ) );\n\t\t\t}\n\t\t\t_transform.Layout.Add( actions );\n\t\t\tif ( context.Kind == RigControlKind.Weapon )\n\t\t\t\tAddVisibilityEditor( context );\n\t\t\tRefresh();\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\t_rebuildingTransformFields = false;\n\t\t}\n\t}\n\n\tprivate Button ToggleAutoKey( Widget parent )\n\t{\n\t\tvar button = new WeaponAnimatorButton( \"Auto-key\", \"fiber_manual_record\", parent )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tIsChecked = _controller.Document.Workspace.AutoKey,\n\t\t\tTint = _controller.Document.Workspace.AutoKey\n\t\t\t\t? WeaponAnimatorTheme.Coral * 0.45f\n\t\t\t\t: WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Toggled = () =>\n\t\t{\n\t\t\t_controller.Mutate(\n\t\t\t\t\"Auto-key\",\n\t\t\t\tdocument => document.Workspace.AutoKey = button.IsChecked );\n\t\t\tRebuildTransform();\n\t\t};\n\t\treturn button;\n\t}\n\n\tprivate void AddVectorRow(\n\t\tstring label,\n\t\tfloat sensitivity,\n\t\tSelectionTransformContext initial,\n\t\tTransformPart part )\n\t{\n\t\tvar row = RigAuditPanel.Row( _transform );\n\t\tvar title = WeaponAnimatorTheme.Label( label, row, true );\n\t\ttitle.FixedWidth = 92;\n\t\trow.Layout.Add( title );\n\t\tvar edits = new LineEdit[3];\n\t\tvar fieldGeneration = _transformFieldGeneration;\n\t\tvar axes = new[] { \"X\", \"Y\", \"Z\" };\n\t\tvar colors = new[]\n\t\t{\n\t\t\tWeaponAnimatorTheme.Coral,\n\t\t\tWeaponAnimatorTheme.Green,\n\t\t\tnew Color( 0.30f, 0.56f, 0.96f )\n\t\t};\n\n\t\tfor ( var index = 0; index < 3; index++ )\n\t\t{\n\t\t\tvar captured = index;\n\t\t\tvar field = new Widget( row )\n\t\t\t{\n\t\t\t\tMinimumWidth = 76,\n\t\t\t\tFixedHeight = 26,\n\t\t\t\tLayout = Layout.Row()\n\t\t\t};\n\t\t\tfield.Layout.Margin = 0;\n\t\t\tfield.Layout.Spacing = 0;\n\t\t\tvar edit = new LineEdit( field ) { FixedHeight = 26 };\n\t\t\tedit.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t\tedit.EditingFinished += () =>\n\t\t\t{\n\t\t\t\tvar current = SelectionTransformContext.Resolve( _controller );\n\t\t\t\tif ( !CanApplyFieldEdit(\n\t\t\t\t\t_rebuildingTransformFields,\n\t\t\t\t\t_refreshingTransformFields,\n\t\t\t\t\tfieldGeneration,\n\t\t\t\t\t_transformFieldGeneration,\n\t\t\t\t\tinitial.Target,\n\t\t\t\t\tinitial.Kind,\n\t\t\t\t\tcurrent ) )\n\t\t\t\t\treturn;\n\t\t\t\tif ( !float.TryParse(\n\t\t\t\t\tedit.Text,\n\t\t\t\t\tNumberStyles.Float,\n\t\t\t\t\tCultureInfo.InvariantCulture,\n\t\t\t\t\tout var value )\n\t\t\t\t\t|| !WeaponAnimationMath.IsFinite( value ) )\n\t\t\t\t\treturn;\n\t\t\t\tApplyAxisValue( part, captured, value, false );\n\t\t\t};\n\t\t\tfield.Layout.Add( new ScrubHandle(\n\t\t\t\taxes[captured],\n\t\t\t\tcolors[captured],\n\t\t\t\tsensitivity,\n\t\t\t\t() => GetVector(\n\t\t\t\t\tSelectionTransformContext.Resolve( _controller )?.DisplayTransform\n\t\t\t\t\t\t?? initial.DisplayTransform,\n\t\t\t\t\tpart )[captured],\n\t\t\t\t() => _controller.BeginContinuousEdit( $\"{label} {axes[captured]}\" ),\n\t\t\t\tvalue => ApplyAxisValue( part, captured, value, true ),\n\t\t\t\t_controller.EndContinuousEdit,\n\t\t\t\tfield ) );\n\t\t\tfield.Layout.Add( edit, 1 );\n\t\t\trow.Layout.Add( field, 1 );\n\t\t\tedits[index] = edit;\n\t\t}\n\n\t\t_refreshers.Add( () =>\n\t\t{\n\t\t\tvar current = SelectionTransformContext.Resolve( _controller );\n\t\t\tif ( current is null )\n\t\t\t\treturn;\n\t\t\tvar vector = GetVector( current.DisplayTransform, part );\n\t\t\tfor ( var index = 0; index < 3; index++ )\n\t\t\t{\n\t\t\t\tvar text = vector[index].ToString( \"0.###\", CultureInfo.InvariantCulture );\n\t\t\t\tif ( edits[index].Text != text )\n\t\t\t\t\tedits[index].Value = text;\n\t\t\t}\n\t\t} );\n\t\t_transform.Layout.Add( row );\n\t}\n\n\tinternal static bool CanApplyFieldEdit(\n\t\tbool rebuilding,\n\t\tbool refreshing,\n\t\tint fieldGeneration,\n\t\tint currentGeneration,\n\t\tstring expectedTarget,\n\t\tRigControlKind expectedKind,\n\t\tSelectionTransformContext? current ) =>\n\t\t!rebuilding\n\t\t&& !refreshing\n\t\t&& fieldGeneration == currentGeneration\n\t\t&& current is not null\n\t\t&& current.Target.Equals( expectedTarget, StringComparison.OrdinalIgnoreCase )\n\t\t&& current.Kind == expectedKind;\n\n\tprivate void ApplyAxisValue(\n\t\tTransformPart part,\n\t\tint axis,\n\t\tfloat value,\n\t\tbool continuous )\n\t{\n\t\tvar context = SelectionTransformContext.Resolve( _controller );\n\t\tif ( context is null )\n\t\t\treturn;\n\t\tvar displayed = context.DisplayTransform;\n\t\tvar vector = GetVector( displayed, part );\n\t\tvector[axis] = part == TransformPart.Scale ? MathF.Max( value, 0.0001f ) : value;\n\t\tdisplayed = SetVector( displayed, part, vector );\n\t\tvar local = context.ToLocal( displayed );\n\t\tif ( continuous )\n\t\t\t_controller.UpdateTransformEditContinuous( context.Target, context.Kind, local );\n\t\telse\n\t\t\t_controller.ApplyTransformEdit( context.Target, context.Kind, local );\n\t}\n\n\tprivate void Refresh()\n\t{\n\t\tif ( _lastLocalGizmos != _controller.Document.Workspace.LocalGizmos )\n\t\t{\n\t\t\tRebuildTransform();\n\t\t\treturn;\n\t\t}\n\n\t\tRebuildChecklist();\n\t\tRefreshPose();\n\t}\n\n\tprivate void RefreshPose()\n\t{\n\t\tvar context = SelectionTransformContext.Resolve( _controller );\n\t\tRefreshIdentity( context );\n\t\t_refreshingTransformFields = true;\n\t\ttry\n\t\t{\n\t\t\tforeach ( var refresh in _refreshers )\n\t\t\t\trefresh();\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\t_refreshingTransformFields = false;\n\t\t}\n\t}\n\n\tprivate void RefreshIdentity( SelectionTransformContext? context )\n\t{\n\t\tif ( context is null )\n\t\t{\n\t\t\t_identity.SetStyles(\n\t\t\t\t\"background-color: rgb(25,28,32); border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;\" );\n\t\t\t_identitySpine.SetStyles(\n\t\t\t\t$\"background-color: {WeaponAnimatorTheme.Muted.Hex}; border: none; border-radius: 0px;\" );\n\t\t\t_type.Text = \"NO SELECTION\";\n\t\t\t_name.Text = \"Select a control or bone\";\n\t\t\t_details.Text = \"Choose an item in the rig browser.\";\n\t\t\t_keyState.Text = \"No key\";\n\t\t\treturn;\n\t\t}\n\n\t\t_type.Text = context.TypeName;\n\t\tvar accent = context.Kind == RigControlKind.Weapon\n\t\t\t? WeaponAnimatorTheme.Amber\n\t\t\t: WeaponAnimatorTheme.Cyan;\n\t\t_type.Color = accent;\n\t\t_identitySpine.SetStyles(\n\t\t\t$\"background-color: {accent.Hex}; border: none; border-radius: 0px;\" );\n\t\t_identity.SetStyles(\n\t\t\t\"background-color: rgb(25,28,32);\" +\n\t\t\t\"border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;\" );\n\t\t_name.Text = context.DisplayName;\n\t\t_details.Text = string.IsNullOrWhiteSpace( context.ParentName )\n\t\t\t? \"No parent\"\n\t\t\t: $\"Parent: {context.ParentName}\";\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tvar working = clip is not null\n\t\t\t&& _controller.Document.Workspace.GetWorkingPose( clip.Id, context.Target ) is not null;\n\t\t_keyState.Text = working\n\t\t\t? \"\u25c6 Unkeyed changes\"\n\t\t\t: _controller.HasKeyAtPlayhead( context.Target )\n\t\t\t\t? \"\u25c6 Keyed at playhead\"\n\t\t\t\t: \"\u25c7 No key at playhead\";\n\t\t_keyState.Color = working\n\t\t\t? WeaponAnimatorTheme.Amber\n\t\t\t: _controller.HasKeyAtPlayhead( context.Target )\n\t\t\t\t? WeaponAnimatorTheme.Cyan\n\t\t\t\t: WeaponAnimatorTheme.Muted;\n\t}\n\n\tprivate static Vector3 GetVector( Transform transform, TransformPart part ) => part switch\n\t{\n\t\tTransformPart.Position => transform.Position,\n\t\tTransformPart.Rotation => new Vector3(\n\t\t\ttransform.Rotation.Angles().pitch,\n\t\t\ttransform.Rotation.Angles().yaw,\n\t\t\ttransform.Rotation.Angles().roll ),\n\t\t_ => transform.Scale\n\t};\n\n\tprivate static Transform SetVector(\n\t\tTransform transform,\n\t\tTransformPart part,\n\t\tVector3 value ) => part switch\n\t{\n\t\tTransformPart.Position => transform.WithPosition( value ),\n\t\tTransformPart.Rotation => transform.WithRotation( Rotation.From( value.x, value.y, value.z ).Normal ),\n\t\t_ => transform.WithScale( value )\n\t};\n\n\tprivate enum TransformPart\n\t{\n\t\tPosition,\n\t\tRotation,\n\t\tScale\n\t}\n}\n\ninternal sealed class SelectionTransformContext\n{\n\tpublic string Target { get; init; } = \"\";\n\tpublic string DisplayName { get; init; } = \"\";\n\tpublic string ParentName { get; init; } = \"\";\n\tpublic RigControlKind Kind { get; init; }\n\tpublic Transform LocalTransform { get; init; }\n\tpublic Transform WorldTransform { get; init; }\n\tpublic Transform? ParentTransform { get; init; }\n\tpublic bool LocalSpace { get; init; }\n\tpublic Transform DisplayTransform => LocalSpace ? LocalTransform : WorldTransform;\n\tpublic string TypeName => Target switch\n\t{\n\t\t\"@primary_hand\" or \"@support_hand\" => \"HAND IK TARGET\",\n\t\t\"@primary_elbow\" or \"@support_elbow\" => \"ELBOW POLE\",\n\t\t_ when Kind == RigControlKind.Weapon => \"WEAPON BONE\",\n\t\t_ when Kind == RigControlKind.Camera => \"CAMERA BONE\",\n\t\t_ => \"ARM BONE\"\n\t};\n\n\tpublic Transform ToLocal( Transform displayed ) =>\n\t\tLocalSpace || ParentTransform is null\n\t\t\t? displayed\n\t\t\t: ParentTransform.Value.ToLocal( displayed );\n\n\tpublic static SelectionTransformContext? Resolve( WeaponAnimatorController controller )\n\t{\n\t\tvar document = controller.Document;\n\t\tvar clip = document.GetSelectedClip();\n\t\tvar skeleton = HostSkeletonBuilder.BuildCached( document );\n\t\tvar pose = AnimationPoseEvaluator.Evaluate(\n\t\t\tdocument,\n\t\t\tskeleton,\n\t\t\tclip,\n\t\t\tdocument.Workspace.TimelineTime,\n\t\t\tincludeWorkingPose: true );\n\t\tvar control = document.Workspace.SelectedControl;\n\t\tif ( !string.IsNullOrWhiteSpace( control ) )\n\t\t{\n\t\t\tvar target = control switch\n\t\t\t{\n\t\t\t\t\"@primary_hand\" => document.Binding.PrimaryHand,\n\t\t\t\t\"@support_hand\" => document.Binding.SupportHand,\n\t\t\t\t\"@primary_elbow\" => document.Binding.PrimaryElbowPole,\n\t\t\t\t\"@support_elbow\" => document.Binding.SupportElbowPole,\n\t\t\t\t_ => null\n\t\t\t};\n\t\t\tif ( target is null )\n\t\t\t\treturn null;\n\t\t\tvar local = clip is not null\n\t\t\t\t&& document.Workspace.GetWorkingPose( clip.Id, control ) is { } working\n\t\t\t\t\t? working.Transform\n\t\t\t\t\t: clip?.Tracks.FirstOrDefault( x =>\n\t\t\t\t\t\tx.Target.Equals( control, StringComparison.OrdinalIgnoreCase ) ) is { } track\n\t\t\t\t\t\t? WeaponAnimationMath.SampleTrack(\n\t\t\t\t\t\t\ttrack,\n\t\t\t\t\t\t\tdocument.Workspace.TimelineTime,\n\t\t\t\t\t\t\ttarget.Transform )\n\t\t\t\t\t\t: target.Transform;\n\t\t\tTransform? parent = null;\n\t\t\tif ( !string.IsNullOrWhiteSpace( target.AttachedBone )\n\t\t\t\t&& pose.Model.TryGetValue( target.AttachedBone, out var attached ) )\n\t\t\t\tparent = attached;\n\t\t\tvar world = parent is null\n\t\t\t\t? local\n\t\t\t\t: new Transform(\n\t\t\t\t\tparent.Value.PointToWorld( local.Position ),\n\t\t\t\t\tparent.Value.Rotation * local.Rotation,\n\t\t\t\t\tparent.Value.Scale * local.Scale );\n\t\t\treturn new SelectionTransformContext\n\t\t\t{\n\t\t\t\tTarget = control,\n\t\t\t\tDisplayName = target.Name,\n\t\t\t\tParentName = target.AttachedBone,\n\t\t\t\tKind = RigControlKind.Arm,\n\t\t\t\tLocalTransform = local,\n\t\t\t\tWorldTransform = world,\n\t\t\t\tParentTransform = parent,\n\t\t\t\tLocalSpace = document.Workspace.LocalGizmos\n\t\t\t};\n\t\t}\n\n\t\tvar selected = document.Workspace.SelectedBone;\n\t\tif ( string.IsNullOrWhiteSpace( selected )\n\t\t\t|| !skeleton.ByName.TryGetValue( selected, out var bone )\n\t\t\t|| !pose.Local.TryGetValue( selected, out var boneLocal )\n\t\t\t|| !pose.Model.TryGetValue( selected, out var boneWorld ) )\n\t\t\treturn null;\n\t\tTransform? boneParent = null;\n\t\tif ( !string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t&& pose.Model.TryGetValue( bone.ParentName, out var parentModel ) )\n\t\t\tboneParent = parentModel;\n\t\treturn new SelectionTransformContext\n\t\t{\n\t\t\tTarget = selected,\n\t\t\tDisplayName = selected,\n\t\t\tParentName = bone.ParentName,\n\t\t\tKind = bone.IsWeaponBone ? RigControlKind.Weapon : RigControlKind.Arm,\n\t\t\tLocalTransform = boneLocal,\n\t\t\tWorldTransform = boneWorld,\n\t\t\tParentTransform = boneParent,\n\t\t\tLocalSpace = document.Workspace.LocalGizmos\n\t\t};\n\t}\n}\n"
        }
    ]
}