{"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 = \u0022preview-material-v2\u0022;\n\n\tprivate static readonly HashSet\u003Cstring\u003E SupportedImageExtensions =\n\t\tnew( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\t\u0022.png\u0022, \u0022.tga\u0022, \u0022.jpg\u0022, \u0022.jpeg\u0022, \u0022.bmp\u0022, \u0022.tif\u0022, \u0022.tiff\u0022, \u0022.dds\u0022, \u0022.exr\u0022\n\t\t};\n\n\tprivate static readonly string[] NearbyFolderNames =\n\t\t[\u0022textures\u0022, \u0022texture\u0022, \u0022materials\u0022, \u0022material\u0022, \u0022maps\u0022];\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\u003CSourceMaterialBinding\u003E DiscoverAndPreparePreview(\n\t\tstring absoluteSource,\n\t\tstring cacheRoot,\n\t\tAsset modelAsset,\n\t\tModel? model,\n\t\tList\u003CRigAuditIssue\u003E issues,\n\t\tIEnumerable\u003Cstring\u003E? 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 =\u003E !string.IsNullOrWhiteSpace( slot ) )\n\t\t\t?? [] );\n\t\tvar embeddedSlots = DiscoverEmbeddedMaterialNames(\n\t\t\tabsoluteSource,\n\t\t\tcandidates.Select( candidate =\u003E candidate.GroupName ) )\n\t\t\t.Select( name =\u003E $\u0022{name}.vmat\u0022 )\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 =\u003E !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )\n\t\t\t.GroupBy( slot =\u003E NormalizeName( Path.GetFileNameWithoutExtension( slot ) ) )\n\t\t\t.Select( group =\u003E 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 =\u003E candidate.GroupName )\n\t\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t\t.Select( name =\u003E $\u0022{name}.vmat\u0022 ) );\n\t\t}\n\n\t\tvar groups = candidates\n\t\t\t.GroupBy( candidate =\u003E NormalizeName( candidate.GroupName ) )\n\t\t\t.Where( group =\u003E !string.IsNullOrWhiteSpace( group.Key ) )\n\t\t\t.ToDictionary(\n\t\t\t\tgroup =\u003E group.Key,\n\t\t\t\tgroup =\u003E group.ToArray(),\n\t\t\t\tStringComparer.OrdinalIgnoreCase );\n\t\tvar bindings = new List\u003CSourceMaterialBinding\u003E();\n\t\tvar usedNames = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var slot in slots\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( name =\u003E 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 =\u003E candidate.Channel )\n\t\t\t\t\t.OrderBy( group =\u003E group.Key ) )\n\t\t\t\t{\n\t\t\t\t\tvar candidate = channelGroup\n\t\t\t\t\t\t.OrderByDescending( item =\u003E item.Priority )\n\t\t\t\t\t\t.ThenBy( item =\u003E 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\u0026\u0026 !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 = \u0022material.packed_orm\u0022,\n\t\t\t\t\tMessage =\n\t\t\t\t\t\t$\u0022Material \u0027{displayName}\u0027 only has a packed ORM texture. \u0022\n\t\t\t\t\t\t\u002B \u0022Separate color, normal, roughness, or metalness maps are required \u0022\n\t\t\t\t\t\t\u002B \u0022for automatic assignment.\u0022,\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 = \u0022material.textures_missing\u0022,\n\t\t\t\t\tMessage =\n\t\t\t\t\t\t$\u0022No nearby texture set matched material \u0027{displayName}\u0027. \u0022\n\t\t\t\t\t\t\u002B \u0022That slot will use the default material.\u0022,\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\u0026\u0026 binding.FindTexture( WeaponTextureChannel.Roughness ) is null\n\t\t\t\t\u0026\u0026 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 = \u0022material.packed_orm\u0022,\n\t\t\t\t\tMessage =\n\t\t\t\t\t\t$\u0022Material \u0027{displayName}\u0027 only has a packed ORM texture. \u0022\n\t\t\t\t\t\t\u002B \u0022Separate roughness and metalness maps are required for automatic assignment.\u0022,\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\u003CHostMaterialRemap\u003E PreviewRemaps(\n\t\tIEnumerable\u003CSourceMaterialBinding\u003E bindings ) =\u003E\n\t\tbindings\n\t\t\t.Where( binding =\u003E !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )\n\t\t\t.Select( binding =\u003E new HostMaterialRemap(\n\t\t\t\tResourceMaterialSlot( binding.SourceMaterialPath ),\n\t\t\t\tbinding.HasUsableTextures\n\t\t\t\t\t\u0026\u0026 !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )\n\t\t\t\t\t\t? binding.PreviewMaterialPath\n\t\t\t\t\t\t: \u0022materials/default.vmat\u0022 ) )\n\t\t\t.ToArray();\n\n\tpublic static IReadOnlyList\u003CHostMaterialRemap\u003E 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 =\u003E !string.IsNullOrWhiteSpace( binding.SourceMaterialPath ) )\n\t\t\t.Select( binding =\u003E new HostMaterialRemap(\n\t\t\t\tResourceMaterialSlot( binding.SourceMaterialPath ),\n\t\t\t\tbinding.HasUsableTextures\n\t\t\t\t\t? $\u0022{relativeRoot}/materials/{slug}_{binding.OutputName}.vmat\u0022\n\t\t\t\t\t: \u0022materials/default.vmat\u0022 ) )\n\t\t\t.ToArray();\n\t}\n\n\tpublic static bool RequiresPreviewRefresh( WeaponAnimationDocument document ) =\u003E\n\t\tdocument.Source.NeedsModelDocWrapper\n\t\t\u0026\u0026 (document.Source.Materials.Count == 0\n\t\t\t|| document.Source.CompiledModelPath.StartsWith(\n\t\t\t\t\u0022.weaponanim-cache/\u0022,\n\t\t\t\tStringComparison.OrdinalIgnoreCase )\n\t\t\t|| document.Source.Materials.Any( binding =\u003E\n\t\t\t\tPath.GetExtension( binding.SourceMaterialPath ).Equals(\n\t\t\t\t\t\u0022.vmat\u0022,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t|| document.Source.Materials.Any( binding =\u003E\n\t\t\t\tbinding.HasUsableTextures\n\t\t\t\t\u0026\u0026 !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath )\n\t\t\t\t\u0026\u0026 (binding.PreviewMaterialPath.StartsWith(\n\t\t\t\t\t\t\u0022.weaponanim-cache/\u0022,\n\t\t\t\t\t\tStringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t|| binding.PreviewMaterialPath.Contains(\n\t\t\t\t\t\t\u0022/texture-definitions/\u0022,\n\t\t\t\t\t\tStringComparison.OrdinalIgnoreCase )) ));\n\n\tpublic static Dictionary\u003Cstring, string\u003E BuildOutputTextFiles(\n\t\tWeaponAnimationDocument document,\n\t\tstring relativeRoot )\n\t{\n\t\tvar files = new Dictionary\u003Cstring, string\u003E( 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 =\u003E binding.HasUsableTextures ) )\n\t\t{\n\t\t\tvar texturePaths = new Dictionary\u003CWeaponTextureChannel, string\u003E();\n\t\t\tforeach ( var texture in binding.Textures\n\t\t\t\t.Where( texture =\u003E texture.Channel != WeaponTextureChannel.PackedOrm ) )\n\t\t\t{\n\t\t\t\tvar imageName = OutputTextureImageName( slug, binding, texture );\n\t\t\t\tvar imageRelative = $\u0022textures/{imageName}\u0022;\n\t\t\t\tvar texturePath = $\u0022{relativeRoot}/{imageRelative}\u0022;\n\t\t\t\ttexturePaths[texture.Channel] = texturePath;\n\t\t\t}\n\n\t\t\tfiles[$\u0022materials/{slug}_{binding.OutputName}.vmat\u0022] =\n\t\t\t\tWriteVmat( texturePaths );\n\t\t}\n\n\t\treturn files;\n\t}\n\n\tpublic static IReadOnlyList\u003CGeneratedTextureCopy\u003E BuildOutputTextureCopies(\n\t\tWeaponAnimationDocument document )\n\t{\n\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\tvar copies = new List\u003CGeneratedTextureCopy\u003E();\n\t\tforeach ( var binding in document.Source.Materials\n\t\t\t.Where( binding =\u003E binding.HasUsableTextures ) )\n\t\t{\n\t\t\tforeach ( var texture in binding.Textures\n\t\t\t\t.Where( texture =\u003E 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$\u0022textures/{OutputTextureImageName( slug, binding, texture )}\u0022,\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\u003CSourceMaterialBinding\u003E DiscoverForTests(\n\t\tIEnumerable\u003Cstring\u003E materialSlots,\n\t\tIEnumerable\u003Cstring\u003E texturePaths )\n\t{\n\t\tvar candidates = texturePaths\n\t\t\t.Select( TryCreateCandidate )\n\t\t\t.Where( candidate =\u003E candidate is not null )\n\t\t\t.Cast\u003CTextureCandidate\u003E()\n\t\t\t.ToArray();\n\t\tvar groups = candidates\n\t\t\t.GroupBy( candidate =\u003E NormalizeName( candidate.GroupName ) )\n\t\t\t.ToDictionary(\n\t\t\t\tgroup =\u003E group.Key,\n\t\t\t\tgroup =\u003E group.ToArray(),\n\t\t\t\tStringComparer.OrdinalIgnoreCase );\n\t\tvar usedNames = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase );\n\t\treturn materialSlots\n\t\t\t.Where( slot =\u003E !IsIgnoredMaterialPath( NormalizeMaterialPath( slot ) ) )\n\t\t\t.Select( slot =\u003E\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 =\u003E candidate.Channel )\n\t\t\t\t\t.Select( channel =\u003E channel.OrderByDescending( item =\u003E item.Priority ).First() )\n\t\t\t\t\t.Select( item =\u003E 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\u003Cstring\u003E MatchEmbeddedMaterialNamesForTests(\n\t\tIEnumerable\u003Cstring\u003E textureGroups,\n\t\tIEnumerable\u003Cstring\u003E embeddedStrings ) =\u003E\n\t\tMatchEmbeddedMaterialNames( textureGroups, embeddedStrings );\n\n\tinternal static string PreviewRevision(\n\t\tIEnumerable\u003CSourceMaterialBinding\u003E bindings )\n\t{\n\t\tvar fingerprint = PreviewMaterialFormatVersion\n\t\t\t\u002B \u0022\\n\u0022\n\t\t\t\u002B string.Join(\n\t\t\t\u0022\\n\u0022,\n\t\t\tbindings\n\t\t\t\t.OrderBy(\n\t\t\t\t\tbinding =\u003E binding.SourceMaterialPath,\n\t\t\t\t\tStringComparer.OrdinalIgnoreCase )\n\t\t\t\t.Select( binding =\u003E\n\t\t\t\t\t$\u0022{NormalizeMaterialPath( binding.SourceMaterialPath )}|{binding.OutputName}|\u0022\n\t\t\t\t\t\u002B string.Join(\n\t\t\t\t\t\t\u0022,\u0022,\n\t\t\t\t\t\tbinding.Textures\n\t\t\t\t\t\t\t.OrderBy( texture =\u003E texture.Channel )\n\t\t\t\t\t\t\t.ThenBy(\n\t\t\t\t\t\t\t\ttexture =\u003E texture.AssetPath,\n\t\t\t\t\t\t\t\tStringComparer.OrdinalIgnoreCase )\n\t\t\t\t\t\t\t.Select( texture =\u003E\n\t\t\t\t\t\t\t\t$\u0022{texture.Channel}:{texture.Sha256}:{texture.AssetPath}\u0022 ) ) ) );\n\t\treturn WeaponSourceImporter.HashText( fingerprint )[..16];\n\t}\n\n\tinternal static string PreviewRevisionRoot(\n\t\tstring cacheRoot,\n\t\tIEnumerable\u003CSourceMaterialBinding\u003E bindings ) =\u003E\n\t\tPath.Combine(\n\t\t\tLegalPreviewCacheRoot( cacheRoot ),\n\t\t\tPreviewRevision( bindings ) );\n\n\tinternal static IReadOnlyList\u003Cstring\u003E PreviewMaterialAbsolutePaths(\n\t\tIEnumerable\u003CSourceMaterialBinding\u003E bindings ) =\u003E\n\t\tbindings\n\t\t\t.Where( binding =\u003E binding.HasUsableTextures\n\t\t\t\t\u0026\u0026 !string.IsNullOrWhiteSpace( binding.PreviewMaterialPath ) )\n\t\t\t.Select( binding =\u003E Path.Combine(\n\t\t\t\tWeaponSourceImporter.GetContentRoot(),\n\t\t\t\tbinding.PreviewMaterialPath.Replace(\n\t\t\t\t\t\u0027/\u0027,\n\t\t\t\t\tPath.DirectorySeparatorChar ) ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\n\tinternal static IReadOnlyList\u003Cstring\u003E PreviewTextureAbsolutePaths(\n\t\tIEnumerable\u003CSourceMaterialBinding\u003E bindings ) =\u003E\n\t\tbindings\n\t\t\t.SelectMany( binding =\u003E binding.Textures )\n\t\t\t.Where( texture =\u003E texture.Channel != WeaponTextureChannel.PackedOrm\n\t\t\t\t\u0026\u0026 !string.IsNullOrWhiteSpace( texture.AssetPath ) )\n\t\t\t.Select( texture =\u003E Path.Combine(\n\t\t\t\tWeaponSourceImporter.GetContentRoot(),\n\t\t\t\ttexture.AssetPath.Replace(\n\t\t\t\t\t\u0027/\u0027,\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 $\u0022weaponanim_preview_cache/{documentFolder}\u0022;\n\t}\n\n\tprivate static void PreparePreviewAssets(\n\t\tIEnumerable\u003CSourceMaterialBinding\u003E 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, \u0022materials\u0022 );\n\t\tDirectory.CreateDirectory( materialRoot );\n\t\tAtomicFile.WriteAllText(\n\t\t\tPath.Combine( legalCacheRoot, \u0022.weaponanim-preview-version\u0022 ),\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 \u0022stopped existing\u0022.\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 = \u0022materials/default.vmat\u0022;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar texturePaths = new Dictionary\u003CWeaponTextureChannel, string\u003E();\n\t\t\tforeach ( var texture in binding.Textures\n\t\t\t\t.Where( texture =\u003E 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, $\u0022{binding.OutputName}.vmat\u0022 );\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\u003Cstring\u003E DiscoverMaterialSlots( Asset modelAsset, Model? model )\n\t{\n\t\tvar slots = new List\u003Cstring\u003E();\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$\u0022[Weapon Animator] could not inspect unresolved source material slots: {ex.Message}\u0022 );\n\t\t}\n\n\t\tif ( model is not null \u0026\u0026 !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 =\u003E 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$\u0022[Weapon Animator] could not inspect compiled model material slots: {ex.Message}\u0022 );\n\t\t\t}\n\t\t}\n\t\treturn slots\n\t\t\t.Select( NormalizeMaterialPath )\n\t\t\t.Where( path =\u003E !path.Contains(\n\t\t\t\t\u0022.weaponanim-cache/\u0022,\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t.Where( path =\u003E !IsIgnoredMaterialPath( path ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToList();\n\t}\n\n\tprivate static bool IsSourceMaterialPath( string? path ) =\u003E\n\t\t!string.IsNullOrWhiteSpace( path )\n\t\t\u0026\u0026 Path.GetExtension( path ).Equals( \u0022.vmat\u0022, StringComparison.OrdinalIgnoreCase );\n\n\tprivate static List\u003CTextureCandidate\u003E DiscoverTextureCandidates( string sourcePath )\n\t{\n\t\tvar directories = NearbyDirectories( sourcePath );\n\t\tvar files = new HashSet\u003Cstring\u003E( 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 =\u003E 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$\u0022[Weapon Animator] could not inspect nearby texture folder \u0027{directory}\u0027: {ex.Message}\u0022 );\n\t\t\t}\n\t\t}\n\n\t\treturn files\n\t\t\t.Select( TryCreateCandidate )\n\t\t\t.Where( candidate =\u003E candidate is not null )\n\t\t\t.Cast\u003CTextureCandidate\u003E()\n\t\t\t.ToList();\n\t}\n\n\tprivate static IReadOnlyList\u003Cstring\u003E DiscoverEmbeddedMaterialNames(\n\t\tstring sourcePath,\n\t\tIEnumerable\u003Cstring\u003E textureGroups )\n\t{\n\t\tif ( !Path.GetExtension( sourcePath ).Equals(\n\t\t\t\u0022.fbx\u0022,\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$\u0022[Weapon Animator] could not inspect embedded FBX material labels: {ex.Message}\u0022 );\n\t\t\treturn [];\n\t\t}\n\t}\n\n\tprivate static IReadOnlyList\u003Cstring\u003E MatchEmbeddedMaterialNames(\n\t\tIEnumerable\u003Cstring\u003E textureGroups,\n\t\tIEnumerable\u003Cstring\u003E embeddedStrings )\n\t{\n\t\tvar strings = embeddedStrings\n\t\t\t.Where( value =\u003E value.Length is \u003E= 2 and \u003C= 128\n\t\t\t\t\u0026\u0026 !value.Contains( \u0027/\u0027 )\n\t\t\t\t\u0026\u0026 !value.Contains( \u0027\\\\\u0027 )\n\t\t\t\t\u0026\u0026 !value.Contains( \u0027.\u0027 ) )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\t\tvar names = new List\u003Cstring\u003E();\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 =\u003E NormalizeName( value ).Equals(\n\t\t\t\t\tnormalizedGroup,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t.OrderBy( value =\u003E value.Length )\n\t\t\t\t.ThenBy( value =\u003E 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\u003Cstring\u003E 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 )) \u003E 0 )\n\t\t{\n\t\t\tfor ( var index = 0; index \u003C count; index\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar value = buffer[index];\n\t\t\t\tif ( value is \u003E= 32 and \u003C= 126 )\n\t\t\t\t{\n\t\t\t\t\tif ( builder.Length \u003C 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 \u003E= 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 \u003E= 2 )\n\t\t\tyield return builder.ToString();\n\t}\n\n\tprivate static IEnumerable\u003Cstring\u003E 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\u003Cstring\u003E( 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 =\u003E !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 ) \u0026\u0026 found.Add( candidate ) )\n\t\t\t\t\tyield return candidate;\n\t\t\t}\n\n\t\t\tIEnumerable\u003Cstring\u003E 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 =\u003E\n\t\t\t\tNearbyFolderNames.Any( folder =\u003E\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(\u0022occlusion_roughness_metallic\u0022, WeaponTextureChannel.PackedOrm, 100),\n\t\t\t(\u0022occlusionroughnessmetallic\u0022, WeaponTextureChannel.PackedOrm, 100),\n\t\t\t(\u0022normal_opengl\u0022, WeaponTextureChannel.Normal, 145),\n\t\t\t(\u0022normal_gl\u0022, WeaponTextureChannel.Normal, 145),\n\t\t\t(\u0022nrm_gl\u0022, WeaponTextureChannel.Normal, 140),\n\t\t\t(\u0022normal_directx\u0022, WeaponTextureChannel.Normal, 80),\n\t\t\t(\u0022normal_dx\u0022, WeaponTextureChannel.Normal, 80),\n\t\t\t(\u0022nrm_dx\u0022, WeaponTextureChannel.Normal, 75),\n\t\t\t(\u0022base_color\u0022, WeaponTextureChannel.BaseColor, 120),\n\t\t\t(\u0022basecolor\u0022, WeaponTextureChannel.BaseColor, 120),\n\t\t\t(\u0022albedo\u0022, WeaponTextureChannel.BaseColor, 115),\n\t\t\t(\u0022diffuse\u0022, WeaponTextureChannel.BaseColor, 110),\n\t\t\t(\u0022color\u0022, WeaponTextureChannel.BaseColor, 100),\n\t\t\t(\u0022ambient_occlusion\u0022, WeaponTextureChannel.AmbientOcclusion, 120),\n\t\t\t(\u0022ambientocclusion\u0022, WeaponTextureChannel.AmbientOcclusion, 120),\n\t\t\t(\u0022occlusion\u0022, WeaponTextureChannel.AmbientOcclusion, 100),\n\t\t\t(\u0022roughness\u0022, WeaponTextureChannel.Roughness, 120),\n\t\t\t(\u0022rough\u0022, WeaponTextureChannel.Roughness, 110),\n\t\t\t(\u0022metalness\u0022, WeaponTextureChannel.Metalness, 120),\n\t\t\t(\u0022metallic\u0022, WeaponTextureChannel.Metalness, 120),\n\t\t\t(\u0022metal\u0022, WeaponTextureChannel.Metalness, 100),\n\t\t\t(\u0022normal\u0022, WeaponTextureChannel.Normal, 110),\n\t\t\t(\u0022nrm\u0022, WeaponTextureChannel.Normal, 105),\n\t\t\t(\u0022diff\u0022, WeaponTextureChannel.BaseColor, 90),\n\t\t\t(\u0022ao\u0022, WeaponTextureChannel.AmbientOcclusion, 90),\n\t\t\t(\u0022orm\u0022, WeaponTextureChannel.PackedOrm, 90),\n\t\t\t(\u0022rma\u0022, WeaponTextureChannel.PackedOrm, 85),\n\t\t\t(\u0022mra\u0022, WeaponTextureChannel.PackedOrm, 85)\n\t\t};\n\n\t\tforeach ( var pattern in patterns )\n\t\t{\n\t\t\tvar marker = $\u0022_{pattern.Token}\u0022;\n\t\t\tvar index = normalized.LastIndexOf( marker, StringComparison.Ordinal );\n\t\t\tif ( index \u003C 0 \u0026\u0026 normalized.Equals( pattern.Token, StringComparison.Ordinal ) )\n\t\t\t\tindex = 0;\n\t\t\tif ( index \u003C 0 )\n\t\t\t\tcontinue;\n\n\t\t\tvar group = normalized[..index].Trim( \u0027_\u0027 );\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\u003Cstring, TextureCandidate[]\u003E groups )\n\t{\n\t\tvar normalizedMaterial = NormalizeName( materialName );\n\t\tvar best = groups\n\t\t\t.Select( pair =\u003E 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 =\u003E item.Score )\n\t\t\t.FirstOrDefault();\n\t\treturn best is not null \u0026\u0026 best.Score \u003E 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 \u002B 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\u0022source-textures\u0022 );\n\t\tDirectory.CreateDirectory( sourceRoot );\n\t\tvar fileName =\n\t\t\t$\u0022{WeaponAnimationDocument.Slugify( Path.GetFileNameWithoutExtension( source ) )}\u0022\n\t\t\t\u002B $\u0022_{hash[..12]}{Path.GetExtension( source ).ToLowerInvariant()}\u0022;\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( \u0027/\u0027, 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\u0026\u0026 File.Exists( texture.OriginalPath ) )\n\t\t\treturn Path.GetFullPath( texture.OriginalPath );\n\n\t\tthrow new FileNotFoundException(\n\t\t\t$\u0022Texture source for {texture.Channel} is missing.\u0022,\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 = \u0022.png\u0022;\n\t\treturn $\u0022{slug}_{binding.OutputName}_{ChannelSuffix( texture.Channel )}\u0022\n\t\t\t\u002B extension.ToLowerInvariant();\n\t}\n\n\tprivate static string WriteVmat(\n\t\tIReadOnlyDictionary\u003CWeaponTextureChannel, string\u003E textures )\n\t{\n\t\tstring TexturePath( WeaponTextureChannel channel, string fallback ) =\u003E\n\t\t\ttextures.TryGetValue( channel, out var path )\n\t\t\t\t? path.Replace( \u0027\\\\\u0027, \u0027/\u0027 )\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? $$\u0022\u0022\u0022\n\n\t\t\t\t\t\tF_METALNESS_TEXTURE 1\n\t\t\t\t\t\tTextureMetalness \u0022{{metalnessPath.Replace( \u0027\\\\\u0027, \u0027/\u0027 )}}\u0022\n\t\t\t\t\t\u0022\u0022\u0022\n\t\t\t\t: \u0022\u0022;\n\n\t\treturn $$\u0022\u0022\u0022\n\t\t\t// SboxWeaponAnimator generated material.\n\t\t\tLayer0\n\t\t\t{\n\t\t\t\tshader \u0022shaders/complex.shader\u0022\n\n\t\t\t\tF_SPECULAR 1\n\t\t\t\tTextureAmbientOcclusion \u0022{{TexturePath( WeaponTextureChannel.AmbientOcclusion, \u0022materials/default/default_ao.tga\u0022 )}}\u0022\n\t\t\t\tTextureColor \u0022{{TexturePath( WeaponTextureChannel.BaseColor, \u0022materials/default/default_color.tga\u0022 )}}\u0022\n\t\t\t\tTextureNormal \u0022{{TexturePath( WeaponTextureChannel.Normal, \u0022materials/default/default_normal.tga\u0022 )}}\u0022\n\t\t\t\tTextureRoughness \u0022{{TexturePath( WeaponTextureChannel.Roughness, \u0022materials/default/default_rough.tga\u0022 )}}\u0022{{metalness}}\n\t\t\t\tg_flModelTintAmount \u00221.000\u0022\n\t\t\t\tg_vColorTint \u0022[1.000000 1.000000 1.000000 0.000000]\u0022\n\t\t\t\tg_flRoughnessScaleFactor \u00221.000\u0022\n\t\t\t\tg_bFogEnabled \u00221\u0022\n\t\t\t}\n\t\t\t\u0022\u0022\u0022;\n\t}\n\n\tprivate static string NormalizeMaterialPath( string value )\n\t{\n\t\tvar normalized = value.Replace( \u0027\\\\\u0027, \u0027/\u0027 ).Trim();\n\t\tif ( !Path.HasExtension( normalized ) )\n\t\t\tnormalized \u002B= \u0022.vmat\u0022;\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( \u0022.vmat\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t? normalized[..^5]\n\t\t\t: normalized;\n\t}\n\n\tprivate static string ResourceMaterialSlot( string value ) =\u003E\n\t\tNormalizeMaterialPath( value );\n\n\tprivate static bool IsIgnoredMaterialPath( string path ) =\u003E\n\t\tpath.Equals( \u0022materials/default.vmat\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t|| path.Equals( \u0022materials/error.vmat\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t|| path.Equals(\n\t\t\t\u0022materials/tools/toolsinvisible.vmat\u0022,\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\u0027/\u0027,\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( \u0027_\u0027 );\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( \u0027_\u0027 );\n\t}\n\n\tprivate static string NormalizeName( string value ) =\u003E\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\u003Cstring\u003E usedNames )\n\t{\n\t\tif ( string.IsNullOrWhiteSpace( baseName ) )\n\t\t\tbaseName = \u0022material\u0022;\n\t\tvar candidate = baseName;\n\t\tvar suffix = 2;\n\t\twhile ( !usedNames.Add( candidate ) )\n\t\t\tcandidate = $\u0022{baseName}_{suffix\u002B\u002B}\u0022;\n\t\treturn candidate;\n\t}\n\n\tprivate static string ChannelSuffix( WeaponTextureChannel channel ) =\u003E channel switch\n\t{\n\t\tWeaponTextureChannel.BaseColor =\u003E \u0022color\u0022,\n\t\tWeaponTextureChannel.Normal =\u003E \u0022normal\u0022,\n\t\tWeaponTextureChannel.Roughness =\u003E \u0022roughness\u0022,\n\t\tWeaponTextureChannel.Metalness =\u003E \u0022metalness\u0022,\n\t\tWeaponTextureChannel.AmbientOcclusion =\u003E \u0022ao\u0022,\n\t\t_ =\u003E \u0022orm\u0022\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\u003Cstring\u003E Failures { get; } = [];\n\tpublic bool Success =\u003E Failures.Count == 0;\n\n\tpublic override string ToString() =\u003E Success\n\t\t? $\u0022Weapon Animator self-tests passed ({Passed} checks).\u0022\n\t\t: $\u0022Weapon Animator self-tests failed ({Failures.Count} failures, {Passed} checks passed):\\n\u0022 \u002B\n\t\t\tstring.Join( \u0022\\n\u0022, Failures.Select( x =\u003E $\u0022  \u2022 {x}\u0022 ) );\n}\n\npublic static class WeaponAnimatorSelfTests\n{\n\tpublic static WeaponAnimatorSelfTestReport RunAll()\n\t{\n\t\tvar report = new WeaponAnimatorSelfTestReport();\n\t\tRun( report, \u0022document roles\u0022, TestDocumentRoles );\n\t\tRun( report, \u0022custom clip management and document title\u0022, TestCustomClipManagement );\n\t\tRun( report, \u0022scale and units\u0022, TestScaleAndUnits );\n\t\tRun( report, \u0022anchor lifecycle\u0022, TestAnchorLifecycle );\n\t\tRun( report, \u0022default grip binding\u0022, TestDefaultGripBinding );\n\t\tRun( report, \u0022weapon subtree filtering\u0022, TestWeaponSubtreeFiltering );\n\t\tRun( report, \u0022rig browser grouping\u0022, TestRigBrowserGrouping );\n\t\tRun( report, \u0022bind pose parity\u0022, TestBindPoseParity );\n\t\tRun( report, \u0022neutral arm binding\u0022, TestNeutralArmBinding );\n\t\tRun( report, \u0022generated Idle recovery\u0022, TestGeneratedIdleRecovery );\n\t\tRun( report, \u0022selection field isolation\u0022, TestSelectionFieldIsolation );\n\t\tRun( report, \u0022working pose and auto-key\u0022, TestWorkingPose );\n\t\tRun( report, \u0022stepped part visibility\u0022, TestPartVisibility );\n\t\tRun( report, \u0022schema migration\u0022, TestSchemaMigration );\n\t\tRun( report, \u0022content-sized buttons\u0022, TestContentSizedButtons );\n\t\tRun( report, \u0022alignment\u0022, TestAlignment );\n\t\tRun( report, \u0022track interpolation\u0022, TestInterpolation );\n\t\tRun( report, \u0022curve editor v2\u0022, TestCurveEditorV2 );\n\t\tRun( report, \u0022frame snapping\u0022, TestFrameSnapping );\n\t\tRun( report, \u0022timeline navigation\u0022, TestTimelineNavigation );\n\t\tRun( report, \u0022timeline selection and movement\u0022, TestTimelineSelectionAndMovement );\n\t\tRun( report, \u0022timeline key reversal\u0022, TestTimelineKeyReversal );\n\t\tRun( report, \u0022timeline playback\u0022, TestTimelinePlayback );\n\t\tRun( report, \u0022two-bone IK\u0022, TestTwoBoneIk );\n\t\tRun( report, \u0022IK descendant propagation\u0022, TestIkDescendantPropagation );\n\t\tRun( report, \u0022timed constraints before IK\u0022, TestConstraintDrivenIk );\n\t\tRun( report, \u0022constraint maintained offset\u0022, TestConstraintMaintainedOffset );\n\t\tRun( report, \u0022history and key clipboard\u0022, TestControllerHistoryAndClipboard );\n\t\tRun( report, \u0022host skeleton cache invalidation\u0022, TestHostSkeletonCache );\n\t\tRun( report, \u0022calibration and generation validation\u0022, TestValidation );\n\t\tRun( report, \u0022generation output paths\u0022, TestGenerationOutputPaths );\n\t\tRun( report, \u0022material discovery and output\u0022, TestMaterialPipeline );\n\t\tRun( report, \u0022generated file removal\u0022, TestGeneratedFileRemoval );\n\t\tRun( report, \u0022calibration rebase\u0022, TestRebase );\n\t\tRun( report, \u0022DMX output\u0022, TestDmxOutput );\n\t\tRun( report, \u0022filtered source wrapper\u0022, TestFilteredSourceWrapper );\n\t\tRun( report, \u0022generation source adapters\u0022, TestGenerationSourceAdapters );\n\t\tRun( report, \u0022deterministic generated text\u0022, TestDeterministicOutput );\n\t\tRun( report, \u0022AnimGraph tags and fallbacks\u0022, TestAnimGraphTagsAndFallbacks );\n\t\treturn report;\n\t}\n\n\t[Menu( \u0022Editor\u0022, \u0022Tools/Weapon Animator/Run Self Tests\u0022, \u0022science\u0022 )]\n\tpublic static void RunFromEditor()\n\t{\n\t\tvar report = RunAll();\n\t\tif ( report.Success )\n\t\t\tLog.Info( $\u0022[Weapon Animator] {report}\u0022 );\n\t\telse\n\t\t\tLog.Error( $\u0022[Weapon Animator] {report}\u0022 );\n\t}\n\n\tprivate static void TestDocumentRoles( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Test Rifle\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tWeaponAnimationDocument.StandardClips().Count,\n\t\t\tdocument.Clips.Count,\n\t\t\t\u0022Default document must contain every standard slot.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tWeaponClipRole.Idle,\n\t\t\tdocument.GetSelectedClip()!.Role,\n\t\t\t\u0022Idle must be selected in a new document.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Workspace.ShowGuides,\n\t\t\t\u0022Viewport guides must be opt-in for new projects.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Workspace.FreeLookCamera,\n\t\t\t\u0022New projects must open with the familiar orbit camera.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Workspace.FullBrightViewport,\n\t\t\t\u0022New projects must open with lit viewport rendering.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.RimLightEnabled,\n\t\t\t\u0022The cyan viewport edge light must remain available by default.\u0022 );\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\u0022The edge light default must be restrained rather than the old over-bright value.\u0022 );\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\u0022The free-look camera must start at normal movement speed.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.SnapRotation,\n\t\t\t\u0022Rotation snapping must be enabled in new projects.\u0022 );\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\u0022Rotation snapping must start at the familiar 15-degree step.\u0022 );\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\u0022The snap-angle stepper must advance through the standard angle presets.\u0022 );\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\u0022The snap-angle stepper must move backward through the standard angle presets.\u0022 );\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\u0022The snap-angle stepper must retain its lower bound.\u0022 );\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\u0022The snap-angle stepper must retain its upper bound.\u0022 );\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\u0022Free-look wheel-up must increase low movement speeds in fine steps.\u0022 );\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\u0022Free-look wheel-down must decrease low movement speeds in fine steps.\u0022 );\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\u0022Free-look movement speed must remain within its upper bound.\u0022 );\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\u0022Free-look movement speed must remain within its lower bound.\u0022 );\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\u0022The default viewport grid must be substantially quieter than the editor grid.\u0022 );\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\u0022The default viewport grid must use fine lines.\u0022 );\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\u0022The opacity preference must affect the colored origin axes.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgridStyle.AxisWidth \u003C 1\n\t\t\t\t\u0026\u0026 gridStyle.AxisWidth \u003E gridStyle.MajorWidth\n\t\t\t\t\u0026\u0026 gridStyle.MajorWidth \u003E gridStyle.MinorWidth,\n\t\t\t\u0022The line-weight preference must allow thin axes while preserving grid hierarchy.\u0022 );\n\t\tvar faintStyle = GridVisualStyle.Resolve( 0.02f, 0.1f );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfaintStyle.AxisOpacity \u003C gridStyle.AxisOpacity\n\t\t\t\t\u0026\u0026 faintStyle.AxisWidth \u003C gridStyle.AxisWidth,\n\t\t\t\u0022Lower opacity and weight must visibly affect both primary and secondary grid lines.\u0022 );\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\u0022The edge-light preference must enable the cyan point light in lit mode.\u0022 );\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\u0022The viewport must apply the persisted edge-light brightness.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ViewportRimLightStyle.Resolve( true, 4, true ).Enabled\n\t\t\t\t\u0026\u0026 !ViewportRimLightStyle.Resolve( false, 4, false ).Enabled,\n\t\t\t\u0022Full Bright and the explicit toggle must both disable the edge light.\u0022 );\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\u0022Edge-light brightness must remain inside its supported range.\u0022 );\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\u0026\u0026 MathF.Max(\n\t\t\t\t\tfullBrightArms.Tint.r,\n\t\t\t\t\tMathF.Max( fullBrightArms.Tint.g, fullBrightArms.Tint.b ) ) \u003E 0.1f,\n\t\t\t\u0022Full Bright must use a visible neutral arms material instead of rendering skin black.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!ArmPreviewVisualStyle.Resolve( WeaponAnimatorStage.Animate, false ).UseFlatMaterial,\n\t\t\t\u0022Lit animation preview must preserve the production arms materials.\u0022 );\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\u0022hand_R_to_L_ikrule\u0022,\n\t\t\t\u0022hand_L_to_R_ikrule\u0022,\n\t\t\t\u0022hand_R_to_weapon_ikrule\u0022,\n\t\t\t\u0022hand_L_to_weapon_ikrule\u0022,\n\t\t\t\u0022ik_hand_R\u0022,\n\t\t\t\u0022ik_hand_L\u0022,\n\t\t\t\u0022weapon_IK_hand_R\u0022,\n\t\t\t\u0022weapon_IK_hand_L\u0022\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$\u0022{ikName} must be treated as an IK helper bone.\u0022 );\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 = \u0022weapon_IK_hand_R\u0022, IsWeaponBone = true } )\n\t\t\t\t\t== SkeletonBoneKind.Ik,\n\t\t\t\u0022An IK target from the weapon rig must be treated as an IK helper, not a weapon bone.\u0022 );\n\t\t// The trap in letting IK win: \u0022ik\u0022 must match as a token, never as a substring.\n\t\tforeach ( var keptName in new[]\n\t\t{\n\t\t\t\u0022weapon_root\u0022,\n\t\t\t\u0022spike_guard\u0022,\n\t\t\t\u0022strike_plate\u0022,\n\t\t\t\u0022trigger\u0022,\n\t\t\t\u0022slide_kick\u0022,\n\t\t\t\u0022ikon\u0022\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$\u0022{keptName} must stay a visible weapon bone - \u0027ik\u0027 matches tokens, not substrings.\u0022 );\n\t\t}\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonBoneStyle.Classify( new HostBone { Name = \u0022arm_lower_R_twist1\u0022 } )\n\t\t\t\t\t== SkeletonBoneKind.Twist\n\t\t\t\t\u0026\u0026 SkeletonBoneStyle.Classify( new HostBone { Name = \u0022arm_lower_R_twistctrl0\u0022 } )\n\t\t\t\t\t== SkeletonBoneKind.Twist\n\t\t\t\t\u0026\u0026 SkeletonBoneStyle.Classify( new HostBone { Name = \u0022hand_R\u0022 } )\n\t\t\t\t\t== SkeletonBoneKind.Arm,\n\t\t\t\u0022Twist helpers must be distinguished from the arm chain proper.\u0022 );\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 \u0026\u0026 shownIk.Visible \u0026\u0026 shownIk.Color == WeaponAnimatorTheme.Coral,\n\t\t\t\u0022IK bones must be hidden by default and drawn red when enabled.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonBoneStyle.Resolve( SkeletonBoneKind.Arm, 2, 8, false ).Visible\n\t\t\t\t\u0026\u0026 SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Visible,\n\t\t\t\u0022Hiding IK bones must not hide anything else.\u0022 );\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\u0026\u0026 twistStyle.AlphaScale \u003C armStyle.AlphaScale\n\t\t\t\t\u0026\u0026 twistStyle.Color == armStyle.Color,\n\t\t\t\u0022Twist bones must recede without changing hue or becoming unclickable.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttwistStyle.Hollow\n\t\t\t\t\u0026\u0026 shownIk.Hollow\n\t\t\t\t\u0026\u0026 !armStyle.Hollow\n\t\t\t\t\u0026\u0026 !SkeletonBoneStyle.Resolve( SkeletonBoneKind.Weapon, 0, 8, false ).Hollow,\n\t\t\t\u0022Derived bones must be hollow and directly posed bones solid, so shape carries the distinction.\u0022 );\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\u0022Weapon bones must stay amber regardless of depth.\u0022 );\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 \u0026\u0026 midColor != tipColor \u0026\u0026 rootColor != tipColor,\n\t\t\t\u0022The arm gradient must separate root, mid-chain and fingertip bones.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttipColor.r \u003E rootColor.r \u0026\u0026 tipColor.g \u003E rootColor.g,\n\t\t\t\u0022The arm gradient must brighten toward the fingertips.\u0022 );\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\u002B ((a.g - b.g) * (a.g - b.g))\n\t\t\t\t\u002B ((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 \u003C 8; depth\u002B\u002B )\n\t\t\tweakestStep = MathF.Min( weakestStep, Separation( depth, depth \u002B 1 ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\tweakestStep \u003E 0.15f,\n\t\t\t\u0022Every step along the arm gradient must be clearly distinguishable from the next.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSeparation( 0, 8 ) \u003E 1.0f,\n\t\t\t\u0022The gradient must travel a long way between the root and the fingertips.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimatorTheme.BoneDepthColor( -5 ) == WeaponAnimatorTheme.BoneDepthColor( 0 )\n\t\t\t\t\u0026\u0026 WeaponAnimatorTheme.BoneDepthColor( 5 ) == WeaponAnimatorTheme.BoneDepthColor( 1 )\n\t\t\t\t\u0026\u0026 WeaponAnimatorTheme.BoneDepthColor( float.NaN )\n\t\t\t\t\t== WeaponAnimatorTheme.BoneDepthColor( 0 ),\n\t\t\t\u0022Out-of-range and non-finite depth fractions must clamp to the ramp ends.\u0022 );\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\u0022A skeleton with no measurable depth must not divide by zero.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Workspace.ShowIkBones,\n\t\t\t\u0022IK bones must be hidden by default.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.BoneOcclusionEnabled,\n\t\t\t\u0022Dynamic bone occlusion must be enabled by default.\u0022 );\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 \u0022behind something\u0022 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 ) \u003C Saturation( vividBone ) * 0.35f,\n\t\t\t\u0022Occluded bones must lose most of their colour so they stop competing for attention.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSaturation( occludedBone ) \u003E 0.001f,\n\t\t\t\u0022Occluded bones must keep a trace of colour so weapon and arm stay tellable apart.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOverlayStyle.Occlude( Color.White.WithAlpha( 0.4f ) ).a == 0.4f,\n\t\t\t\u0022Draining colour must not disturb the alpha the occluded pass already applies.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOverlayStyle.OccludedDotScale \u003C 1.0f\n\t\t\t\t\u0026\u0026 SkeletonOverlayStyle.OccludedLineThickness \u003C 1.0f,\n\t\t\t\u0022Occluded bones must draw smaller so they do not veil bones in front.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tmiddleLine.Thickness \u003C visibleLine.Thickness\n\t\t\t\t\u0026\u0026 middleLine.Thickness \u003E hiddenLine.Thickness\n\t\t\t\t\u0026\u0026 middleLine.Color.a \u003C visibleLine.Color.a\n\t\t\t\t\u0026\u0026 middleLine.Color.a \u003E hiddenLine.Color.a\n\t\t\t\t\u0026\u0026 middleLine.Color != visibleLine.Color\n\t\t\t\t\u0026\u0026 middleLine.Color != hiddenLine.Color,\n\t\t\t\u0022A mixed-visibility connection must gradient its colour, opacity, and width.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOverlayStyle.OcclusionDepthClearance( 80 )\n\t\t\t\t\u003E SkeletonOverlayStyle.OcclusionDepthClearance( 10 )\n\t\t\t\t\u0026\u0026 SkeletonOverlayStyle.OcclusionDepthClearance( float.NaN ) \u003E 0,\n\t\t\t\u0022Occlusion clearance must follow marker size and remain valid for bad camera distances.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 80.015f )\n\t\t\t\t\u0026\u0026 !SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.95f )\n\t\t\t\t\u0026\u0026 SkeletonOverlayStyle.IsOccludingDepth( 80.015f, 79.0f ),\n\t\t\t\u0022A surface at the bone endpoint must remain visible while a nearer surface occludes it.\u0022 );\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\u0022Bones hidden behind the arms must be visible by default.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\txrayStyle.DrawThroughMeshes\n\t\t\t\t\u0026\u0026 xrayStyle.OccludedAlpha \u003E 0\n\t\t\t\t\u0026\u0026 xrayStyle.OccludedAlpha \u003C 1.0f,\n\t\t\t\u0022Occluded bones must stay visible but subordinate to unoccluded ones.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SkeletonOverlayStyle.Resolve( false, 1.0f ).DrawThroughMeshes,\n\t\t\t\u0022Disabling x-ray must restore the depth-tested skeleton overlay.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!SkeletonOverlayStyle.Resolve( true, 0 ).DrawThroughMeshes,\n\t\t\t\u0022A fully faded skeleton must not draw through viewport meshes.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tSkeletonOverlayStyle.Resolve( true, 0.18f ).OccludedAlpha \u003C xrayStyle.OccludedAlpha,\n\t\t\t\u0022Fainter skeleton passes must produce proportionally fainter ghosts.\u0022 );\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\u0022Overlay alpha must remain inside its supported range.\u0022 );\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\u0022A non-finite overlay alpha must fall back to the default.\u0022 );\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\u0026\u0026 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\u0022Each finger must ignore its own hand mesh and reduce only behind the opposite hand.\u0022 );\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\u0022Weapon bones must reduce only when an arm is actually in front.\u0022 );\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\u0026\u0026 !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\u0022Unowned and same-side surfaces must never reduce an arm bone.\u0022 );\n\n\t\tvar first = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tvar second = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tfirst.Name = second.Name = \u0022Mechanical Check\u0022;\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationNames.SequenceName( first ) != WeaponAnimationNames.SequenceName( second ),\n\t\t\t\u0022Custom sequence names must remain unique.\u0022 );\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\u0026\u0026 !first.GeneratedSequenceName.Contains( first.Id.ToString( \u0022N\u0022 ), StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !second.GeneratedSequenceName.Contains( second.Id.ToString( \u0022N\u0022 ), StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 first.GeneratedSequenceName != second.GeneratedSequenceName,\n\t\t\t\u0022Custom clips must receive stable, readable sequence names with short collision suffixes.\u0022 );\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\u0026\u0026 first.GeneratedSequenceName == customSequence,\n\t\t\t\u0022Resolved custom sequence names must remain stable across later repairs.\u0022 );\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\u0026\u0026 anchorKind == AnchorKind.Muzzle,\n\t\t\t\u0022Calibration anchor control names must round-trip.\u0022 );\n\n\t\tvar muzzleAnchor = new WeaponAnchor { Kind = AnchorKind.Muzzle, Name = \u0022Muzzle\u0022 };\n\t\tvar customA = new WeaponAnchor { Kind = AnchorKind.Custom, Name = \u0022Suppressor Mount\u0022 };\n\t\tvar customB = new WeaponAnchor { Kind = AnchorKind.Custom, Name = \u0022Suppressor Mount\u0022 };\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\u0026\u0026 customA.GeneratedAttachmentName == \u0022suppressor_mount\u0022\n\t\t\t\t\u0026\u0026 customB.GeneratedAttachmentName != customA.GeneratedAttachmentName,\n\t\t\t\u0022Custom anchors must take readable attachment names and separate on collision.\u0022 );\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\u0026\u0026 customA.GeneratedAttachmentName == resolvedAnchor,\n\t\t\t\u0022Resolved custom attachment names must stay stable across later repairs.\u0022 );\n\t\tcustomA.Name = \u0022Silencer Mount\u0022;\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimationNames.RepairCustomAnchorNames( document )\n\t\t\t\t\u0026\u0026 WeaponAnimationNames.AttachmentName( customA ) == resolvedAnchor,\n\t\t\t\u0022Renaming a custom anchor must not silently rename the generated attachment.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationNames.AttachmentName( muzzleAnchor ) == \u0022muzzle\u0022,\n\t\t\t\u0022Fixed anchor kinds must keep their reserved attachment names.\u0022 );\n\t\tvar reservedClash = new WeaponAnchor { Kind = AnchorKind.Custom, Name = \u0022Muzzle\u0022 };\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\u0026\u0026 reservedClash.GeneratedAttachmentName != \u0022muzzle\u0022,\n\t\t\t\u0022A custom anchor must not claim an attachment name reserved by a fixed kind.\u0022 );\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\u0026\u0026 customAnchorId == customA.Id\n\t\t\t\t\u0026\u0026 CalibrationSelection.Resolve(\n\t\t\t\t\tdocument,\n\t\t\t\t\tCalibrationSelection.Anchor( customB ) ) == customB,\n\t\t\t\u0022Custom anchor selection tokens must round-trip to the individual anchor.\u0022 );\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\u0026\u0026 CalibrationSelection.TryGetAnchor(\n\t\t\t\t\tCalibrationSelection.Anchor( customA ),\n\t\t\t\t\tout var customKind )\n\t\t\t\t\u0026\u0026 customKind == AnchorKind.Custom,\n\t\t\t\u0022Fixed anchor tokens must carry no id, and custom tokens must still report their kind.\u0022 );\n\t\tdocument.Calibration.Anchors.Clear();\n\n\t\t// A .wepanim created outside the New Project flow arrives carrying CreateDefault()\u0027s\n\t\t// \u0022New Weapon\u0022, 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 == \u0022new_weapon\u0022,\n\t\t\t\u0022The default document must still carry the documented placeholder asset name.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimatorWindow.AdoptAssetFileName( named, \u0022weapons/test2.wepanim\u0022 )\n\t\t\t\t\u0026\u0026 named.Name == \u0022test2\u0022\n\t\t\t\t\u0026\u0026 named.Output.AssetName == \u0022test2\u0022\n\t\t\t\t\u0026\u0026 named.Output.GetDefaultRelativeFolder() == \u0022weapons/test2/viewmodel\u0022,\n\t\t\t\u0022Opening a project must adopt its filename for generated names and folders.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimatorWindow.AdoptAssetFileName( named, \u0022weapons/test2.wepanim\u0022 ),\n\t\t\t\u0022Adopting an unchanged filename must not mark the document dirty.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimatorWindow.AdoptAssetFileName( named, \u0022weapons/AK 74.wepanim\u0022 )\n\t\t\t\t\u0026\u0026 named.Name == \u0022AK 74\u0022\n\t\t\t\t\u0026\u0026 named.Output.AssetName == \u0022ak_74\u0022,\n\t\t\t\u0022Save As must rename generated output, slugifying the display name.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimatorWindow.AdoptAssetFileName( named, \u0022\u0022 )\n\t\t\t\t\u0026\u0026 !WeaponAnimatorWindow.AdoptAssetFileName( named, (string?)null )\n\t\t\t\t\u0026\u0026 named.Output.AssetName == \u0022ak_74\u0022,\n\t\t\t\u0022An unsaved project must keep its existing generated name.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022Alignment marker \u2014 rear\u0022,\n\t\t\tCalibrationSelection.DisplayName( AnchorKind.RearBore ),\n\t\t\t\u0022Auto-align markers must use purpose-driven names.\u0022 );\n\n\t\tdocument.Workspace.AnimationRightSplitterState = \u0022right-column-layout\u0022;\n\t\tvar reopened = Json.Deserialize\u003CWeaponAnimationDocument\u003E( Json.Serialize( document ) )!;\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022right-column-layout\u0022,\n\t\t\treopened.Workspace.AnimationRightSplitterState,\n\t\t\t\u0022The selected-control and clip-rack splitter must persist with the workspace.\u0022 );\n\t}\n\n\tprivate static void TestCustomClipManagement(\n\t\tWeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Internal Name\u0022 );\n\t\tvar first = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tfirst.Name = \u0022Mechanical Check\u0022;\n\t\tvar second = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tsecond.Name = \u0022Mechanical Check\u0022;\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 = \u0022weapon_root\u0022\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, \u0022Safety Check\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022Safety Check\u0022,\n\t\t\tfirst.Name,\n\t\t\t\u0022Custom clips must be renameable.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022safety_check\u0022,\n\t\t\tfirst.GeneratedSequenceName,\n\t\t\t\u0022Renaming a custom clip must assign a readable collision-safe sequence name.\u0022 );\n\t\tcontroller.Undo();\n\t\tvar restoredFirst = controller.Document.Clips.First( clip =\u003E clip.Id == first.Id );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022Mechanical Check\u0022,\n\t\t\trestoredFirst.Name,\n\t\t\t\u0022Custom clip rename must be one undoable action.\u0022 );\n\n\t\tcontroller.DeleteCustomClip( first.Id );\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.Document.Clips.All( clip =\u003E clip.Id != first.Id )\n\t\t\t\t\u0026\u0026 controller.Document.Workspace.WorkingPoseOverrides.All(\n\t\t\t\t\titem =\u003E item.ClipId != first.Id )\n\t\t\t\t\u0026\u0026 controller.Document.Workspace.TimelineViews.All(\n\t\t\t\t\titem =\u003E item.ClipId != first.Id )\n\t\t\t\t\u0026\u0026 controller.Document.Workspace.CurveViews.All(\n\t\t\t\t\titem =\u003E item.ClipId != first.Id ),\n\t\t\t\u0022Deleting a custom clip must remove its clip-owned workspace state.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tWeaponClipRole.Idle,\n\t\t\tcontroller.Document.GetSelectedClip()!.Role,\n\t\t\t\u0022Deleting the selected custom clip must return selection to Idle.\u0022 );\n\t\tcontroller.Undo();\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.Document.Clips.Any( clip =\u003E clip.Id == first.Id ),\n\t\t\t\u0022Custom clip deletion must restore the complete clip through one undo.\u0022 );\n\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022S\u0026box Weapon Animator \u2014 p30l.wepanim\u0022,\n\t\t\tWeaponAnimatorWindow.ComposeWindowTitle(\n\t\t\t\t\u0022weapons/pistols/p30l.wepanim\u0022,\n\t\t\t\t\u0022New Weapon\u0022,\n\t\t\t\tfalse ),\n\t\t\t\u0022The window title must use the open asset filename instead of the stale document name.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022S\u0026box Weapon Animator \u2014 p30l.wepanim *\u0022,\n\t\t\tWeaponAnimatorWindow.ComposeWindowTitle(\n\t\t\t\t\u0022weapons/pistols/p30l.wepanim\u0022,\n\t\t\t\t\u0022New Weapon\u0022,\n\t\t\t\ttrue ),\n\t\t\t\u0022The filename caption must retain the dirty marker.\u0022 );\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\u0022A valid metric measurement should calculate scale.\u0022 );\n\t\tNear( report, 1, preview.UniformScale, 0.0001f, \u002225.4 cm over 10 units should scale to one inch per unit.\u0022 );\n\t\tNear( report, 25.4f, WeaponAnimationMath.ToCentimetres( 10 ), 0.0001f, \u0022Unit conversion must be exact.\u0022 );\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\u0022Coincident measurement points must be rejected.\u0022 );\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 =\u003E anchor.Kind == AnchorKind.Eject ),\n\t\t\t\u0022Repicking an anchor must replace it instead of creating an ambiguous duplicate.\u0022 );\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\u0022Repicking an anchor must update its editable position.\u0022 );\n\n\t\tdocument.Calibration.Anchors.RemoveAll( anchor =\u003E anchor.Kind == AnchorKind.Eject );\n\t\tCheck( report, document.Calibration.GetAnchor( AnchorKind.Eject ) is null, \u0022Optional anchors must be individually deletable.\u0022 );\n\t\tdocument.Calibration.Anchors.RemoveAll( anchor =\u003E anchor.Kind == AnchorKind.Grip );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,\n\t\t\t\u0022Deleting a required anchor must reopen its calibration requirement.\u0022 );\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\u0022A calibrated grip must seed the animation page\u0027s primary-hand target.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022weapon_root\u0022,\n\t\t\tdocument.Binding.PrimaryHand.AttachedBone,\n\t\t\t\u0022The primary hand must default to the canonical weapon root attachment.\u0022 );\n\t\tvar skeleton = HostSkeletonBuilder.Build( document, includeArmProfile: false );\n\t\tvar primaryWorld = skeleton.ByName[\u0022weapon_root\u0022].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\u0022The primary-hand target must include physical and viewmodel placement.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!document.Binding.PrimaryHand.IsBound,\n\t\t\t\u0022Seeding the primary target must not enable IK before the user binds the hand.\u0022 );\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 = \u0022weapon_root\u0022,\n\t\t\tBones =\n\t\t\t[\n\t\t\t\tDefinition( \u0022weapon_root\u0022, \u0022\u0022, WeaponBoneClassification.WeaponRoot, Vector3.Zero ),\n\t\t\t\tDefinition( \u0022receiver\u0022, \u0022weapon_root\u0022, WeaponBoneClassification.Animatable, new Vector3( 1, 0, 0 ) ),\n\t\t\t\tDefinition( \u0022slide_any_name\u0022, \u0022receiver\u0022, WeaponBoneClassification.Animatable, new Vector3( 2, 0, 0 ) ),\n\t\t\t\tDefinition( \u0022foreign_branch_947\u0022, \u0022weapon_root\u0022, WeaponBoneClassification.Animatable, new Vector3( 0, 1, 0 ) ),\n\t\t\t\tDefinition( \u0022mystery_child\u0022, \u0022foreign_branch_947\u0022, 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, \u0022weapon_root\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponRigHierarchy.ExcludeBranch( rig, \u0022foreign_branch_947\u0022 ),\n\t\t\t\u0022An arbitrary foreign branch must be excludable without name heuristics.\u0022 );\n\t\tWeaponRigHierarchy.ConfirmFilteredPreview( rig );\n\n\t\tCheck( report, rig.FindBone( \u0022receiver\u0022 )!.Inclusion == WeaponBoneInclusion.Included, \u0022Weapon descendants must remain included.\u0022 );\n\t\tCheck( report, rig.FindBone( \u0022mystery_child\u0022 )!.Inclusion == WeaponBoneInclusion.Excluded, \u0022Excluding a branch must exclude every descendant.\u0022 );\n\t\tCheck( report, !rig.ReviewRequired \u0026\u0026 rig.FilteredPreviewConfirmed, \u0022Confirming the filtered preview must close the rig-review gate.\u0022 );\n\t\tvar auditSignature = RigAuditPanel.BoneStructureSignature( rig, \u0022\u0022, 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, \u0022\u0022, true, true, false ),\n\t\t\t\u0022Non-structural document refreshes must not rebuild the rig-audit bone rows.\u0022 );\n\t\trig.FindBone( \u0022receiver\u0022 )!.Classification = WeaponBoneClassification.Structural;\n\t\tCheck(\n\t\t\treport,\n\t\t\tauditSignature != RigAuditPanel.BoneStructureSignature( rig, \u0022\u0022, true, true, false ),\n\t\t\t\u0022Classification changes must rebuild the rig-audit bone rows.\u0022 );\n\t\trig.FindBone( \u0022receiver\u0022 )!.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( \u0022slide_any_name\u0022 ), \u0022Retained arbitrary weapon bones must enter the host.\u0022 );\n\t\tCheck( report, !skeleton.ByName.ContainsKey( \u0022foreign_branch_947\u0022 ), \u0022Excluded branches must never enter the host.\u0022 );\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 = \u0022weapon_root\u0022,\n\t\t\tBones =\n\t\t\t[\n\t\t\t\tDefinition( \u0022weapon_root\u0022, \u0022\u0022, WeaponBoneClassification.WeaponRoot, rootModel ),\n\t\t\t\tDefinition( \u0022rotated_part\u0022, \u0022weapon_root\u0022, 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, \u0022Stage 2 must reproduce every Stage 1 weapon bind transform.\u0022 );\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[\u0022rotated_part\u0022].BindModelTransform.Position,\n\t\t\t0.0001f,\n\t\t\t\u0022A rotated child must not receive an extra root-space rotation.\u0022 );\n\t\tNear(\n\t\t\treport,\n\t\t\texpected.Rotation.Forward,\n\t\t\tskeleton.ByName[\u0022rotated_part\u0022].BindModelTransform.Rotation.Forward,\n\t\t\t0.0001f,\n\t\t\t\u0022Child orientation must match calibration exactly.\u0022 );\n\t\tvar pose = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );\n\t\tvar definition = document.Rig.FindBone( \u0022rotated_part\u0022 )!;\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\u0022A retained source bone must resolve to a host pose override.\u0022 );\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\u0022Source renderer overrides must use the host\u0027s world position.\u0022 );\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\u0022Source renderer overrides must not reinterpret model-space rotation as world-space rotation.\u0022 );\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\u0022Source renderer overrides must include calibration scale exactly once.\u0022 );\n\t\tvar solvedRenderer = WeaponPoseProjection.SolveRendererTransform(\n\t\t\trootModel,\n\t\t\tskeleton.ByName[\u0022weapon_root\u0022].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\u0022Native source binds must recover the calibration renderer position.\u0022 );\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\u0022Native source binds must recover the calibration renderer rotation.\u0022 );\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\u0022Native source binds must recover the calibration renderer scale.\u0022 );\n\n\t\tvar rebuiltHierarchy = new HostSkeleton();\n\t\trebuiltHierarchy.Add( new HostBone\n\t\t{\n\t\t\tName = \u0022root\u0022,\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 = \u0022weapon_root\u0022,\n\t\t\tParentName = \u0022root\u0022,\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 = \u0022weapon_helper\u0022,\n\t\t\tParentName = \u0022weapon_root\u0022,\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[\u0022weapon_root\u0022].BindModelTransform,\n\t\t\trebuiltHierarchy.ByName[\u0022weapon_helper\u0022].BindLocalTransform );\n\t\tNear(\n\t\t\treport,\n\t\t\texpectedHelper.Position,\n\t\t\trebuiltHierarchy.ByName[\u0022weapon_helper\u0022].BindModelTransform.Position,\n\t\t\t0.0001f,\n\t\t\t\u0022Changing weapon_root must rebuild canonical helper model transforms from their untouched local binds.\u0022 );\n\t\tNear(\n\t\t\treport,\n\t\t\texpectedHelper.Scale,\n\t\t\trebuiltHierarchy.ByName[\u0022weapon_helper\u0022].BindModelTransform.Scale,\n\t\t\t0.0001f,\n\t\t\t\u0022Rebuilt helper binds must preserve the calibrated parent scale exactly once.\u0022 );\n\t\tvar compilerBinds = rebuiltHierarchy.BuildCompilerBindModelTransforms();\n\t\tvar compilerRoot = compilerBinds[\u0022weapon_root\u0022];\n\t\tvar compilerHelper = compilerBinds[\u0022weapon_helper\u0022];\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\u0022Compiled bind expectations must model ModelDoc\u0027s scale-one skeleton.\u0022 );\n\t\tNear(\n\t\t\treport,\n\t\t\trebuiltHierarchy.ByName[\u0022weapon_helper\u0022].BindModelTransform.Position,\n\t\t\tcompilerHelper.Position,\n\t\t\t0.0001f,\n\t\t\t\u0022Compiled bind expectations must preserve scale-baked physical child pivots.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022weapon_helper\u0022,\n\t\t\trebuiltHierarchy.ChildrenOf( \u0022weapon_root\u0022 ).Single().Name,\n\t\t\t\u0022Host skeletons must retain a direct parent-to-children lookup.\u0022 );\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\u0022Unchanged rig inputs must reuse the cached animation-host skeleton.\u0022 );\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\u0022Calibration changes must invalidate the cached animation-host skeleton.\u0022 );\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\u0022Sub-display-precision transform changes must invalidate the host cache.\u0022 );\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 = \u0022bolt\u0022, IsWeaponBone = true },\n\t\t\tnew HostBone { Name = \u0022arm_upper_R\u0022 },\n\t\t\tnew HostBone { Name = \u0022arm_upper_L\u0022 },\n\t\t\tnew HostBone { Name = \u0022finger_index_0_R\u0022 },\n\t\t\tnew HostBone { Name = \u0022camera\u0022 }\n\t\t};\n\t\tvar groups = bones.Select( RigBrowserPanel.GroupName ).ToArray();\n\t\tEqual( report, \u0022Weapon\u0022, groups[0], \u0022Weapon-domain bones must appear in the Weapon group.\u0022 );\n\t\tEqual( report, \u0022Right arm\u0022, groups[1], \u0022Right-side Facepunch bones must appear in the Right arm group.\u0022 );\n\t\tEqual( report, \u0022Left arm\u0022, groups[2], \u0022Left-side Facepunch bones must appear in the Left arm group.\u0022 );\n\t\tEqual( report, \u0022Fingers\u0022, groups[3], \u0022Finger bones must remain in their dedicated group.\u0022 );\n\t\tEqual( report, \u0022Advanced\u0022, groups[4], \u0022Canonical utility bones must appear in Advanced.\u0022 );\n\t\tEqual( report, bones.Length, groups.Length, \u0022Every host bone must be assigned to exactly one rig-browser group.\u0022 );\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\u0022Pose and selection changes must not invalidate the rig-browser structure.\u0022 );\n\t\tmatchingSkeleton.Add( new HostBone { Name = \u0022new_bone\u0022, 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\u0022An actual hierarchy change must invalidate the rig-browser structure.\u0022 );\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( \u0022root\u0022, \u0022\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022arm_upper_R\u0022, \u0022root\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022arm_lower_R\u0022, \u0022arm_upper_R\u0022, new Vector3( 1, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022hand_R\u0022, \u0022arm_lower_R\u0022, new Vector3( 2, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022arm_upper_L\u0022, \u0022root\u0022, Vector3.Zero ) );\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tskeleton.ByName[\u0022arm_lower_R\u0022].ArmSide,\n\t\t\t\u0022Host bones must cache their inherited right-arm side without per-sample traversal.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t-1,\n\t\t\tskeleton.ByName[\u0022arm_upper_L\u0022].ArmSide,\n\t\t\t\u0022Host bones must cache their left-arm side when added.\u0022 );\n\n\t\tvar neutral = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );\n\t\tNear( report, new Vector3( 2, 0, 0 ), neutral.Model[\u0022hand_R\u0022].Position, 0.0001f, \u0022An unbound arm must remain in its default pose.\u0022 );\n\t\tvar idle = document.GetSelectedClip()!;\n\t\tvar accidentalTrack = idle.EnsureTrack( \u0022arm_upper_R\u0022 );\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[\u0022arm_upper_R\u0022].Position,\n\t\t\t0.0001f,\n\t\t\t\u0022An unbound right arm must ignore authored or stale right-arm tracks.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!AnimationPoseEvaluator.ShouldEvaluateTrack( document, skeleton, accidentalTrack ),\n\t\t\t\u0022The evaluator must explicitly gate an unbound arm track.\u0022 );\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\u0022Binding the primary hand must enable its arm tracks.\u0022 );\n\t\tvar leftTrack = idle.EnsureTrack( \u0022arm_upper_L\u0022 );\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\u0022One-handed primary binding must not enable left-arm tracks.\u0022 );\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[\u0022hand_R\u0022].Position, 0.001f, \u0022Explicitly binding the hand must enable IK.\u0022 );\n\t\tdocument.Binding.PrimaryHand.IsBound = false;\n\t\tvar restored = AnimationPoseEvaluator.Evaluate( document, skeleton, null, 0 );\n\t\tNear( report, neutral.Model[\u0022hand_R\u0022].Position, restored.Model[\u0022hand_R\u0022].Position, 0.0001f, \u0022Unbinding must restore the default pose.\u0022 );\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 = \u0022weapon_root/slide\u0022,\n\t\t\tParentId = \u0022weapon_root\u0022,\n\t\t\tHierarchyPath = \u0022weapon_root/slide\u0022,\n\t\t\tName = \u0022slide\u0022,\n\t\t\tParentName = \u0022weapon_root\u0022,\n\t\t\tOriginalName = \u0022slide\u0022,\n\t\t\tOriginalParentName = \u0022weapon_root\u0022,\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 =\u003E x.Target == \u0022weapon_root\u0022 ).Keys[0].Scale =\n\t\t\tnew Vector3( 0.55f );\n\t\tidle.Tracks.First( x =\u003E x.Target == \u0022slide\u0022 ).Keys[0].Position \u002B=\n\t\t\tnew Vector3( 1.052f, 0, 0 );\n\t\tvar staleArm = idle.EnsureTrack( \u0022clavicle_R\u0022 );\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\u0022A pristine one-key Idle polluted by selection callbacks must be recoverable.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tidle.IsBindPoseSeed\n\t\t\t\t\u0026\u0026 idle.Tracks.Count == skeleton.Bones.Count( x =\u003E x.IsWeaponBone )\n\t\t\t\t\u0026\u0026 idle.Tracks.All( x =\u003E x.Kind == RigControlKind.Weapon ),\n\t\t\t\u0022Recovery must leave only canonical weapon bind tracks.\u0022 );\n\t\tforeach ( var bone in skeleton.Bones.Where( x =\u003E x.IsWeaponBone ) )\n\t\t{\n\t\t\tvar key = idle.Tracks.Single( x =\u003E 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$\u0022Recovered {bone.Name} position must match its authoritative bind.\u0022 );\n\t\t}\n\n\t\tvar authored = Json.Deserialize\u003CWeaponAnimationDocument\u003E( Json.Serialize( document ) )!;\n\t\tauthored.EnsureClip( WeaponClipRole.Fire ).EnsureTrack( \u0022weapon_root\u0022 ).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 \u002B= Vector3.One;\n\t\tCheck(\n\t\t\treport,\n\t\t\t!IdleBindPoseService.RepairUnintendedSelectionWrites( authored, authoredSkeleton ),\n\t\t\t\u0022Recovery must not rewrite a project after action animation has been authored.\u0022 );\n\n\t\tvar controller = new WeaponAnimatorController();\n\t\tcontroller.SetDocument( document );\n\t\tcontroller.UpsertSelectedTransformKey(\n\t\t\t\u0022weapon_root\u0022,\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\u0022An intentional key edit must permanently mark the Idle clip as authored.\u0022 );\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 = \u0022slide\u0022,\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\u0022weapon_root\u0022,\n\t\t\t\tRigControlKind.Weapon,\n\t\t\t\tcurrent ),\n\t\t\t\u0022A focus-loss callback from the previous bone must not edit the new selection.\u0022 );\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\u0022slide\u0022,\n\t\t\t\tRigControlKind.Weapon,\n\t\t\t\tcurrent ),\n\t\t\t\u0022Programmatic field refresh must never be interpreted as a typed edit.\u0022 );\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\u0022slide\u0022,\n\t\t\t\tRigControlKind.Weapon,\n\t\t\t\tcurrent ),\n\t\t\t\u0022A callback from a destroyed field generation must not edit the rebuilt inspector.\u0022 );\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\u0022slide\u0022,\n\t\t\t\tRigControlKind.Weapon,\n\t\t\t\tcurrent ),\n\t\t\t\u0022A genuine edit on the still-selected target must remain available.\u0022 );\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( \u0022root\u0022, \u0022\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022weapon_root\u0022, \u0022root\u0022, 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\u0022weapon_root\u0022,\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[\u0022weapon_root\u0022].Position,\n\t\t\t0.0001f,\n\t\t\t\u0022Unkeyed working poses must not leak into export evaluation.\u0022 );\n\t\tNear(\n\t\t\treport,\n\t\t\tworking.Position,\n\t\t\tpreview.Local[\u0022weapon_root\u0022].Position,\n\t\t\t0.0001f,\n\t\t\t\u0022The editor preview must include the active working pose.\u0022 );\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\u0022Working poses must not affect deterministic animation output.\u0022 );\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\u0022weapon_root\u0022,\n\t\t\tRigControlKind.Weapon,\n\t\t\tworking );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.GetWorkingPose( clip.Id, \u0022weapon_root\u0022 ) is not null\n\t\t\t\t\u0026\u0026 clip.Tracks.All( x =\u003E x.Target != \u0022weapon_root\u0022 || x.Keys.Count == 0 ),\n\t\t\t\u0022Auto-key off must store an unkeyed working pose.\u0022 );\n\t\tcontroller.CommitWorkingPose(\n\t\t\t\u0022weapon_root\u0022,\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, \u0022weapon_root\u0022 ) is null\n\t\t\t\t\u0026\u0026 controller.HasKeyAtPlayhead( \u0022weapon_root\u0022 ),\n\t\t\t\u0022Committing a working pose must create a key and clear its override.\u0022 );\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\u0022weapon_root\u0022,\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 =\u003E x.Target == \u0022weapon_root\u0022 ).Keys[0].Position,\n\t\t\t0.0001f,\n\t\t\t\u0022Auto-key on must write the edited transform at the playhead.\u0022 );\n\n\t\tvar second = document.EnsureClip( WeaponClipRole.Fire );\n\t\tdocument.Workspace.SetWorkingPose(\n\t\t\tsecond.Id,\n\t\t\t\u0022weapon_root\u0022,\n\t\t\tRigControlKind.Weapon,\n\t\t\tworking );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdocument.Workspace.GetWorkingPose( second.Id, \u0022weapon_root\u0022 ) is not null\n\t\t\t\t\u0026\u0026 document.Workspace.GetWorkingPose( clip.Id, \u0022weapon_root\u0022 ) is null,\n\t\t\t\u0022Working poses must remain isolated per clip.\u0022 );\n\n\t\tvar serialized = Json.Serialize( document );\n\t\tvar reopened = Json.Deserialize\u003CWeaponAnimationDocument\u003E( serialized )!;\n\t\tCheck(\n\t\t\treport,\n\t\t\treopened.Workspace.GetWorkingPose( second.Id, \u0022weapon_root\u0022 ) is not null,\n\t\t\t\u0022Working poses must survive document save and reopen.\u0022 );\n\n\t\tcontroller.SelectClip( second.Id );\n\t\tdocument.Workspace.AutoKey = false;\n\t\tcontroller.BeginContinuousEdit( \u0022Scrub weapon root X\u0022 );\n\t\tcontroller.UpdateTransformEditContinuous(\n\t\t\t\u0022weapon_root\u0022,\n\t\t\tRigControlKind.Weapon,\n\t\t\tworking.WithPosition( new Vector3( 9, 0, 0 ) ) );\n\t\tcontroller.UpdateTransformEditContinuous(\n\t\t\t\u0022weapon_root\u0022,\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, \u0022weapon_root\u0022 )!.Transform.Position,\n\t\t\t0.0001f,\n\t\t\t\u0022A complete scrub drag must collapse into one undo action.\u0022 );\n\n\t\tvar beforeCalibration = controller.Document.Calibration.PhysicalTransform;\n\t\tcontroller.BeginContinuousEdit( \u0022Move calibrated weapon\u0022 );\n\t\tcontroller.UpdateContinuousEdit( current =\u003E\n\t\t\tcurrent.Calibration.PhysicalTransform =\n\t\t\t\tbeforeCalibration.WithPosition( new Vector3( 1, 2, 3 ) ) );\n\t\tcontroller.UpdateContinuousEdit( current =\u003E\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\u0022A complete calibration gizmo drag must collapse into one undo action.\u0022 );\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\u0022@primary_hand\u0022,\n\t\t\t\t\u0022weapon_root\u0022 ),\n\t\t\t\u0022Choosing a hand attachment must accept canonical weapon bones.\u0022 );\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\u0022Attaching a hand must preserve its world pose by rebasing into weapon-local space.\u0022 );\n\t\tHandAttachmentService.ChangeAttachment(\n\t\t\tattachmentDocument,\n\t\t\t\u0022@primary_hand\u0022,\n\t\t\t\u0022\u0022 );\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\u0022Returning a hand to world space must preserve its visible pose.\u0022 );\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 = \u0022weapon_root\u0022;\n\t\t\tattachedDocument.Binding.PrimaryHand.Transform = new Transform( new Vector3( 2, 0, 0 ) );\n\t\t\tattachedController.SelectControl( \u0022@primary_hand\u0022 );\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\u0022Attached hand targets must display relative to their weapon bone in Local space.\u0022 );\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\u0022World space must display the evaluated target transform.\u0022 );\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\u0022World-space edits must convert back through the attached weapon bone.\u0022 );\n\t\t\tattachedDocument.Workspace.LocalGizmos = true;\n\t\t\tattachedDocument.Binding.PrimaryHand.AttachedBone = \u0022\u0022;\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tSelectionTransformContext.Resolve( attachedController )!.LocalSpace,\n\t\t\t\t\u0022The global Local toggle must also drive unattached control labels and axes.\u0022 );\n\t\t}\n\t\telse\n\t\t{\n\t\t\treport.Passed \u002B= 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 \u002B 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\u0022A gizmo world delta must be converted through the parent exactly once.\u0022 );\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\u0022Local scale gizmos must apply independent axis factors and clamp above zero.\u0022 );\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\u0022World scale gizmos must convert through the evaluated parent exactly once.\u0022 );\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 = \u0022legacy_root\u0022;\n\t\tdocument.Rig.Bones =\n\t\t[\n\t\t\tnew WeaponBoneDefinition\n\t\t\t{\n\t\t\t\tName = \u0022legacy_root\u0022,\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 = \u0022bolt_random\u0022,\n\t\t\t\tParentName = \u0022legacy_root\u0022,\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 = \u0022legacy_root\u0022, Kind = RigControlKind.Weapon },\n\t\t\tnew TransformTrack { Target = \u0022bolt_random\u0022, Kind = RigControlKind.Weapon },\n\t\t\tnew TransformTrack { Target = \u0022hand_R\u0022, 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, \u0022A version 2 document must migrate to the separated-rig schema.\u0022 );\n\t\tEqual( report, 2, result.PreservedWeaponTracks, \u0022Migration must preserve weapon tracks.\u0022 );\n\t\tEqual( report, 1, result.RemovedTracks, \u0022Migration must reset old arm tracks.\u0022 );\n\t\tCheck( report, idle.Tracks.Any( x =\u003E x.Target == \u0022weapon_root\u0022 ), \u0022The legacy root track must map to canonical weapon_root.\u0022 );\n\t\tCheck( report, !document.Binding.PrimaryHand.IsBound, \u0022Migration must reset hand binding.\u0022 );\n\t\tCheck( report, document.ActiveStage == WeaponAnimatorStage.Calibrate \u0026\u0026 document.Rig.ReviewRequired, \u0022Migration must return to the rig-review gate.\u0022 );\n\t\tNear( report, new Vector3( 5, 2, 1 ), document.Calibration.PhysicalTransform.Position, 0.0001f, \u0022Migration must preserve calibration placement.\u0022 );\n\n\t\tvar legacyIdle = ValidDocument();\n\t\tlegacyIdle.Rig.Bones.Add( new WeaponBoneDefinition\n\t\t{\n\t\t\tId = \u0022weapon_root/slide\u0022,\n\t\t\tParentId = \u0022weapon_root\u0022,\n\t\t\tHierarchyPath = \u0022weapon_root/slide\u0022,\n\t\t\tName = \u0022slide\u0022,\n\t\t\tParentName = \u0022weapon_root\u0022,\n\t\t\tOriginalName = \u0022slide\u0022,\n\t\t\tOriginalParentName = \u0022weapon_root\u0022,\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( \u0022weapon_root\u0022 );\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( \u0022slide\u0022 );\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 \u0026\u0026 repair.Changed, \u0022A model-space legacy Idle seed must be repaired on open.\u0022 );\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\u0022Legacy child keys must be restored to parent-local bind space.\u0022 );\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\u0022Legacy root keys must regain the imported source root bind transform.\u0022 );\n\n\t\tvar partiallyRepaired = Json.Deserialize\u003CWeaponAnimationDocument\u003E(\n\t\t\tJson.Serialize( legacyIdle ) )!;\n\t\tvar partialRoot = partiallyRepaired.EnsureClip( WeaponClipRole.Idle )\n\t\t\t.Tracks.First( x =\u003E x.Target == \u0022weapon_root\u0022 );\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[\u0022weapon_root\u0022].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\u0022A previously repaired child pose must still repair a normalized legacy root.\u0022 );\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\u0022Partial-repair recovery must restore the source root without altering child binds.\u0022 );\n\n\t\tvar normalization = ValidDocument();\n\t\tvar normalizationTrack = normalization.EnsureClip( WeaponClipRole.Idle )\n\t\t\t.EnsureTrack( \u0022weapon_root\u0022 );\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 = \u0022Check Action\u0022;\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\u0026\u0026 normalizationTrack.Keys[0].Time == 0\n\t\t\t\t\u0026\u0026 normalizationTrack.Keys[1].Time == 1,\n\t\t\t\u0022Opening a project must normalize transform-key order once for allocation-free sampling.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tnormalized.RepairedSequenceNames\n\t\t\t\t\u0026\u0026 custom.GeneratedSequenceName == \u0022check_action\u0022,\n\t\t\t\u0022Opening a project must persist readable sequence names for existing custom clips.\u0022 );\n\n\t\tvar temporary = Path.Combine( Path.GetTempPath(), $\u0022weaponanim_{Guid.NewGuid():N}.wepanim\u0022 );\n\t\tFile.WriteAllText( temporary, \u0022version two\u0022 );\n\t\ttry\n\t\t{\n\t\t\tvar backup = WeaponAnimationMigration.CreateBackup( temporary, 2 );\n\t\t\tCheck( report, File.Exists( backup ), \u0022Migration must create a recoverable versioned backup before saving.\u0022 );\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\u002B\u002B;\n\t\t\treturn;\n\t\t}\n\n\t\tvar shortButton = new WeaponAnimatorButton( \u0022Undo\u0022, \u0022undo\u0022 );\n\t\tvar longButton = new WeaponAnimatorButton( \u0022Constrain selected control\u0022, \u0022link\u0022 );\n\t\tCheck( report, shortButton.PreferredWidth \u003E 36, \u0022A labelled button must reserve space beyond the icon-only minimum.\u0022 );\n\t\tCheck( report, longButton.PreferredWidth \u003E shortButton.PreferredWidth, \u0022Button width must be measured from its full label.\u0022 );\n\t\tlongButton.FitToContent();\n\t\tCheck( report, longButton.MinimumWidth \u003E= longButton.PreferredWidth, \u0022A content-sized button must expose its measured width to the layout.\u0022 );\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 \u002B iconOnly.IconWidth * 0.5f,\n\t\t\t0.0001f,\n\t\t\t\u0022Icon-only buttons must center the icon without reserving a text gap.\u0022 );\n\t\tshortButton.Destroy();\n\t\tlongButton.Destroy();\n\n\t\tvar toolbar = new WeaponAnimatorToolbar();\n\t\ttoolbar.AddLeft( \u0022Save\u0022, \u0022save\u0022, () =\u003E { } );\n\t\tvar undo = toolbar.AddLeft(\n\t\t\t\u0022Undo\u0022,\n\t\t\t\u0022undo\u0022,\n\t\t\t() =\u003E { },\n\t\t\toverflowAtNarrowWidth: true );\n\t\ttoolbar.AddCenter( \u00221  Calibrate\u0022, \u0022straighten\u0022, () =\u003E { } );\n\t\ttoolbar.AddCenter( \u00222  Animate\u0022, \u0022animation\u0022, () =\u003E { } );\n\t\ttoolbar.AddRight( \u0022Validate\u0022, \u0022rule\u0022, () =\u003E { } );\n\t\ttoolbar.BalanceCenter();\n\t\ttoolbar.ApplyAvailableWidth( 1200 );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttoolbar.UsesOverflow \u0026\u0026 !undo.Visible,\n\t\t\t\u0022At 1200px secondary toolbar actions must move into a readable overflow menu.\u0022 );\n\t\ttoolbar.ApplyAvailableWidth( 1600 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!toolbar.UsesOverflow \u0026\u0026 undo.Visible,\n\t\t\t\u0022At 1600px full toolbar labels must remain visible.\u0022 );\n\t\ttoolbar.ApplyAvailableWidth( 2560 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!toolbar.UsesOverflow \u0026\u0026 undo.Visible,\n\t\t\t\u0022Ultrawide layouts must retain the full toolbar.\u0022 );\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\u0022Changing clips must preserve the clip-rack scroll position.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tclips.PropertiesScroll.VerticalScrollbar.Value,\n\t\t\t\u0022A clip\u0027s properties must open at its remembered position rather than scrolling down.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tReferenceEquals( idleButton, clips.GetClipButton( idleClip.Id ) )\n\t\t\t\t\u0026\u0026 ReferenceEquals( deployButton, clips.GetClipButton( deployClip.Id ) ),\n\t\t\t\u0022Changing clips must update button state in place instead of rebuilding the rack.\u0022 );\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\u0022Clip-property scroll positions must remain independent for each clip.\u0022 );\n\t\tvar timeline = new AnimationTimelinePanel( controller );\n\t\tvar timelineActions = WidgetTree( timeline )\n\t\t\t.OfType\u003CWeaponAnimatorButton\u003E()\n\t\t\t.Where( x =\u003E x.Text is \u0022Add key\u0022 or \u0022Copy\u0022 or \u0022Paste\u0022 or \u0022Reverse\u0022 or \u0022Curves\u0022 )\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\u0022The dope-sheet toolbar must retain its five compact edit actions.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWidgetTree( timeline )\n\t\t\t\t.OfType\u003CWeaponAnimatorButton\u003E()\n\t\t\t\t.All( x =\u003E x.Text != \u0022Mirror\u0022 ),\n\t\t\t\u0022The unsafe rig-dependent Mirror action must not remain in the timeline toolbar.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttimelineActions.All( x =\u003E\n\t\t\t\tx.MinimumWidth \u003E= x.PreferredWidth\n\t\t\t\t\u0026\u0026 x.MinimumWidth \u003C= MathF.Ceiling( x.PreferredWidth ) \u002B 0.1f ),\n\t\t\t\u0022Dope-sheet edit actions must use measured fixed widths instead of stretching.\u0022 );\n\t\tvar loopButton = WidgetTree( timeline )\n\t\t\t.OfType\u003CWeaponAnimatorButton\u003E()\n\t\t\t.FirstOrDefault( button =\u003E button.Icon == \u0022repeat\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tloopButton is not null\n\t\t\t\t\u0026\u0026 loopButton.IsToggle\n\t\t\t\t\u0026\u0026 loopButton.Flat\n\t\t\t\t\u0026\u0026 string.IsNullOrWhiteSpace( loopButton.Text ),\n\t\t\t\u0022The timeline toolbar must expose looping as a flat icon beside its transport controls.\u0022 );\n\t\tvar loopDocumentEvents = 0;\n\t\tvar loopSettingsEvents = 0;\n\t\tcontroller.DocumentChanged \u002B= () =\u003E loopDocumentEvents\u002B\u002B;\n\t\tcontroller.ClipPlaybackSettingsChanged \u002B= () =\u003E loopSettingsEvents\u002B\u002B;\n\t\tcontroller.ToggleSelectedClipLoop();\n\t\tCheck(\n\t\t\treport,\n\t\t\tidleClip.Loop == false \u0026\u0026 loopButton?.IsChecked == false,\n\t\t\t\u0022The loop toggle must update both the selected clip and its toolbar state.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tloopDocumentEvents,\n\t\t\t\u0022Changing loop playback must not rebuild document-driven inspector panels.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tloopSettingsEvents,\n\t\t\t\u0022Changing loop playback must publish one focused transport-state update.\u0022 );\n\t\tNear(\n\t\t\treport,\n\t\t\t500,\n\t\t\tTimelineControlToolbar.CenteredLeft( 1000, 150 ) \u002B 75,\n\t\t\t0.0001f,\n\t\t\t\u0022Timeline transport controls must be centered independently of unequal side content.\u0022 );\n\t\tcontroller.SelectBone( \u0022weapon_root\u0022 );\n\t\tvar firstCount = CountWidgetTree( inspector );\n\t\tcontroller.SelectControl( \u0022@primary_hand\u0022 );\n\t\tcontroller.SelectBone( \u0022weapon_root\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tfirstCount,\n\t\t\tCountWidgetTree( inspector ),\n\t\t\t\u0022Repeated selection rebuilds must keep a constant inspector widget count.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tCountWidgetTree( rigBrowser ) \u003E 5\n\t\t\t\t\u0026\u0026 CountWidgetTree( clips ) \u003E 5\n\t\t\t\t\u0026\u0026 CountWidgetTree( timeline ) \u003E 5,\n\t\t\t\u0022The full-height rig, right-column clip rack, and timeline must build their complete panel trees.\u0022 );\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 ) =\u003E\n\t\t1 \u002B widget.Children.Sum( CountWidgetTree );\n\n\tprivate static IEnumerable\u003CWidget\u003E 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\u0022Valid grip and bore anchors should align.\u0022 );\n\t\tNear( report, canonical, alignment.PhysicalTransform.PointToWorld( grip ), 0.001f, \u0022Grip must land on the canonical origin.\u0022 );\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\u0022Bore must align to viewmodel forward.\u0022 );\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, \u0022Reversed bore points must be detected.\u0022 );\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, \u0022Stepped interpolation must hold.\u0022 );\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, \u0022Linear interpolation must blend position.\u0022 );\n\t\tNear( report, 1, RotationLength( halfway.Rotation ), 0.0001f, \u0022Sampled quaternions must remain normalized.\u0022 );\n\t\ttrack.Interpolation = TrackInterpolation.Cubic;\n\t\tNear( report, 1.56f, WeaponAnimationMath.SampleTrack( track, 0.25f, Transform.Zero ).Position.x, 0.01f, \u0022Cubic interpolation must use smoothstep timing.\u0022 );\n\t}\n\n\tprivate static void TestCurveEditorV2( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Curves\u0022 );\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(\u0022weapon_root\u0022, RigControlKind.Weapon),\n\t\t\t(\u0022finger_index_1_R\u0022, RigControlKind.Arm),\n\t\t\t(\u0022@primary_hand\u0022, RigControlKind.Arm),\n\t\t\t(\u0022camera\u0022, 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\u0022Curve track enumeration must include every keyed weapon, arm, target, and camera track.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tCurveEditingService.KeyedTracks( clip, \u0022finger\u0022 ).Count,\n\t\t\t\u0022Curve track search must filter without truncating the keyed-track source.\u0022 );\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\u0022The Curves toggle must enter persistent curve-editor mode.\u0022 );\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\u0022Selected curve tracks must persist per clip.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t(view.VisibleChannels \u0026 TransformCurveChannel.RotationY) != 0,\n\t\t\t\u0022Multiple visible transform channels must persist together.\u0022 );\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\u0022Ease-in speed curves must normalize to a complete one-span traversal.\u0022 );\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\u0022Ease-in speed must begin at 0\u00D7.\u0022 );\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\u0022Ease-in speed must end at 2\u00D7.\u0022 );\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\u0022Integrated speed must drive monotonic normalized motion progress.\u0022 );\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\u0022Motion-rate curves must clamp negative rates at 0\u00D7.\u0022 );\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\u0022Zero-area speed curves must fall back to linear timing.\u0022 );\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\u0022Position channel tangents must evaluate as cubic Hermite curves.\u0022 );\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\u0022Scale channel tangents must evaluate independently.\u0022 );\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\u0022Custom Euler rotation channels must normalize their output quaternion.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tMathF.Abs( MathF.Abs( rotationSample.Rotation.Angles().yaw ) - 180 ) \u003C 1,\n\t\t\t\u0022Rotation channels must unwrap through the shortest angular path.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t(start.CurveTangents.FreeHandles \u0026 TransformCurveChannel.PositionX) == 0,\n\t\t\t\u0022Curve handles must be aligned by default.\u0022 );\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 \u0026 TransformCurveChannel.PositionX) != 0,\n\t\t\t\u0022Alt-style tangent edits must be able to break one handle side.\u0022 );\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\u0022Handle alignment must restore matching facing tangents.\u0022 );\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 =\u003E 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\u0026\u0026 repaired.Interpolation == TrackInterpolation.Linear,\n\t\t\t\u0022Deleting a curve endpoint must create a safe linear bridge between new neighbors.\u0022 );\n\n\t\tvar legacy = WeaponAnimationDocument.CreateDefault( \u0022Schema 3 curves\u0022 );\n\t\tlegacy.SchemaVersion = 3;\n\t\tvar legacyClip = legacy.EnsureClip( WeaponClipRole.Fire );\n\t\tvar legacyTrack = legacyClip.EnsureTrack( \u0022legacy\u0022 );\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\u0026\u0026 legacy.SchemaVersion == WeaponAnimationDocument.CurrentSchemaVersion,\n\t\t\t\u0022Schema-v3 documents must migrate to schema v4.\u0022 );\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\u0022Schema-v3 migration must preserve exact legacy playback.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tlegacyTrack.CurveSpans.Count == 0,\n\t\t\t\u0022Migration must not materialize custom curve spans until edited.\u0022 );\n\n\t\tvar lifecycleDocument = WeaponAnimationDocument.CreateDefault( \u0022Curve lifecycle\u0022 );\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( \u0022slide\u0022 );\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 =\u003E x.Id, x =\u003E 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 =\u003E x.Target == \u0022slide\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tlifecycleTrack.CurveSpans.Any( x =\u003E x.Id == lifecycleSpanId ),\n\t\t\t\u0022Moving curve endpoints must retain their stable span data.\u0022 );\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 =\u003E x.Target == \u0022slide\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tlifecycleTrack.CurveSpans.Any( x =\u003E\n\t\t\t\tx.HasSpeedCurve\n\t\t\t\t\t\u0026\u0026 lifecycleController.SelectedKeys.Contains( x.StartKeyId )\n\t\t\t\t\t\u0026\u0026 lifecycleController.SelectedKeys.Contains( x.EndKeyId ) ),\n\t\t\t\u0022Copy and paste must preserve a span curve only when both endpoint keys are copied.\u0022 );\n\n\t\tvar invalidDocument = ValidDocument();\n\t\tvar invalidClip = invalidDocument.EnsureClip( WeaponClipRole.Fire );\n\t\tvar invalidTrack = invalidClip.EnsureTrack( \u0022weapon_root\u0022 );\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 =\u003E x.Code == \u0022curve.speed_invalid\u0022 ),\n\t\t\t\u0022Zero-area speed curves must produce an explicit validation warning.\u0022 );\n\n\t\tvar exportDocument = WeaponAnimationDocument.CreateDefault( \u0022Curve export\u0022 );\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( \u0022root\u0022 );\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 = \u0022root\u0022,\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\u0022Customized curves must produce deterministic sampled animation output.\u0022 );\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, \u0022Frame snapping must select the nearest frame.\u0022 );\n\t\tNear( report, 0.34f, WeaponAnimationMath.SnapTime( 0.34f, 30, true ), 0.0001f, \u0022Subframe keys must preserve time.\u0022 );\n\t}\n\n\tprivate static void TestTimelineNavigation( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Timeline navigation\u0022 );\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, \u0022A new timeline view must begin at frame zero.\u0022 );\n\t\tEqual( report, 300, full.EndFrame, \u0022A new timeline view must cover the complete clip.\u0022 );\n\n\t\tvar zoomed = TimelineInteraction.Zoom( new TimelineFrameRange( 60, 240 ), 300, true );\n\t\tEqual( report, 144, zoomed.Span, \u0022Ctrl\u002Bwheel zoom must reduce the visible frame span.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t300,\n\t\t\tzoomed.StartFrame \u002B zoomed.EndFrame,\n\t\t\t\u0022Ctrl\u002Bwheel zoom must preserve the range midpoint.\u0022 );\n\t\tvar panned = TimelineInteraction.Pan( zoomed, 500, 300 );\n\t\tEqual( report, 300, panned.EndFrame, \u0022Range panning must clamp at the clip end.\u0022 );\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\u0022Range handles must retain the minimum two-frame interval.\u0022 );\n\n\t\tvar closeTicks = TimelineInteraction.TickSpacing( 10 );\n\t\tvar wideTicks = TimelineInteraction.TickSpacing( 0.5f );\n\t\tEqual( report, 1, closeTicks.MinorFrames, \u0022Zoomed timelines must expose individual frame ticks.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\twideTicks.MinorFrames \u003E closeTicks.MinorFrames\n\t\t\t\t\u0026\u0026 wideTicks.MajorFrames \u003E closeTicks.MajorFrames,\n\t\t\t\u0022Tick spacing must become coarser as the visible frame density increases.\u0022 );\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\u0022Key markers must snap horizontally to whole pixels.\u0022 );\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\u0022Key markers must remain vertically centered on their row.\u0022 );\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\u0022First-frame diamonds must remain fully inside the graph.\u0022 );\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\u0022Last-frame diamonds must not be covered by the scrollbar gutter.\u0022 );\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\u0022Changing a timeline view must create its per-clip workspace state.\u0022 );\n\t\tNear( report, 1, state!.VisibleStart, 0.0001f, \u0022Timeline range start must persist in seconds.\u0022 );\n\t\tNear( report, 3, state.VisibleEnd, 0.0001f, \u0022Timeline range end must persist in seconds.\u0022 );\n\t\tNear( report, 132, state.VerticalScroll, 0.0001f, \u0022Vertical track scroll must persist per clip.\u0022 );\n\n\t\tclip.Tracks.Add( new TransformTrack { Target = \u0022one\u0022 } );\n\t\tclip.Tracks.Add( new TransformTrack { Target = \u0022two\u0022 } );\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\u0022Timeline row count must include every transform track, visibility track, and the tag row.\u0022 );\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\u0022A plain marquee must replace the previous key selection.\u0022 );\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\u0022Shift-marquee must add intersected keys.\u0022 );\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\u0022Ctrl-marquee must toggle every intersected key.\u0022 );\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\u0022A marquee start must remain anchored to its original track while scrolling.\u0022 );\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\u0022A scrolling marquee endpoint must follow the newly revealed content.\u0022 );\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\u0022A marquee must remain clipped to the graph\u0027s right edge.\u0022 );\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\u0022Moving keys before frame zero must clamp the group as a unit.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t25,\n\t\t\tTimelineInteraction.ClampGroupFrameDelta( [2, 5], 40, 30 ),\n\t\t\t\u0022Moving keys past the clip end must preserve their internal spacing.\u0022 );\n\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Timeline key move\u0022 );\n\t\tvar clip = document.GetSelectedClip()!;\n\t\tclip.Duration = 1;\n\t\tclip.SampleRate = 30;\n\t\tvar track = clip.EnsureTrack( \u0022weapon_root\u0022 );\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\u003CGuid, float\u003E\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\u0022A moved key must replace an unselected key occupying its destination frame.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\ttrack.Keys.Select( x =\u003E TimelineInteraction.TimeToFrame( x.Time, 30 ) )\n\t\t\t\t.SequenceEqual( [4, 7] ),\n\t\t\t\u0022Selected keys must move by the same snapped frame delta.\u0022 );\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( \u0022weapon_root\u0022 ).Keys.Count,\n\t\t\t\u0022A complete key drag must undo as one action.\u0022 );\n\n\t\tvar deleteDocument = WeaponAnimationDocument.CreateDefault( \u0022Timeline key delete\u0022 );\n\t\tvar deleteClip = deleteDocument.GetSelectedClip()!;\n\t\tvar deleteTransformKey = WeaponAnimationMath.UpsertKey(\n\t\t\tdeleteClip.EnsureTrack( \u0022weapon_root\u0022 ),\n\t\t\t0,\n\t\t\tTransform.Zero );\n\t\tvar visibilityPart = new WeaponVisibilityPart { Name = \u0022Magazine\u0022 };\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 =\u003E x.Keys ).Count(),\n\t\t\t\u0022Deleting selected keys must remove transform keys.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tdeleteClip.VisibilityTracks.SelectMany( x =\u003E x.Keys ).Count(),\n\t\t\t\u0022Deleting selected keys must remove visibility keys.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tdeleteController.SelectedKeys.Count,\n\t\t\t\u0022Deleting keys must clear the stale key selection.\u0022 );\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 =\u003E x.Keys ).Count()\n\t\t\t\t\u002B deleteClip.VisibilityTracks.SelectMany( x =\u003E x.Keys ).Count(),\n\t\t\t\u0022Deleting a mixed key selection must undo as one action.\u0022 );\n\t}\n\n\tprivate static void TestTimelineKeyReversal( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Timeline reverse\u0022 );\n\t\tvar clip = document.GetSelectedClip()!;\n\t\tclip.Duration = 1;\n\t\tclip.SampleRate = 30;\n\t\tvar track = clip.EnsureTrack( \u0022weapon_root\u0022 );\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 =\u003E WeaponAnimationMath.SampleTrack( track, x, Transform.Zero ).Position )\n\t\t\t.ToArray();\n\n\t\tvar visibilityPart = new WeaponVisibilityPart { Name = \u0022Magazine\u0022 };\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( \u0022weapon_root\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tend.Id,\n\t\t\ttrack.Keys[0].Id,\n\t\t\t\u0022With no key selection, Reverse must flip all transform keys across the clip.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tstart.Id,\n\t\t\ttrack.Keys[^1].Id,\n\t\t\t\u0022Whole-clip reversal must place the first key at the last frame.\u0022 );\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\u0022Custom curve spans must reverse with their endpoint keys.\u0022 );\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\u0022Reversing a speed curve must swap its endpoint rates.\u0022 );\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\u0022Reversing a speed curve must invert its former end slope.\u0022 );\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\u0022Reversing a speed curve must swap its handle modes.\u0022 );\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\u0022Reversed channel curves must negate the former incoming tangent.\u0022 );\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\u0022Reversed channel curves must negate the former outgoing tangent.\u0022 );\n\t\tfor ( var i = 0; i \u003C sampleTimes.Length; i\u002B\u002B )\n\t\t{\n\t\t\tNear(\n\t\t\t\treport,\n\t\t\t\tsourceSamples[^(i \u002B 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\u0022Reversed custom curves must reproduce the original motion backward.\u0022 );\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\u0022Whole-clip reversal must also flip visibility keys.\u0022 );\n\n\t\tcontroller.Undo();\n\t\tclip = controller.Document.GetSelectedClip()!;\n\t\ttrack = clip.EnsureTrack( \u0022weapon_root\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tstart.Id,\n\t\t\ttrack.Keys[0].Id,\n\t\t\t\u0022Transform, curve, and visibility reversal must undo as one action.\u0022 );\n\n\t\tvar selectionDocument = WeaponAnimationDocument.CreateDefault( \u0022Selected reverse\u0022 );\n\t\tvar selectionClip = selectionDocument.GetSelectedClip()!;\n\t\tselectionClip.Duration = 2;\n\t\tselectionClip.SampleRate = 10;\n\t\tvar selectionTrack = selectionClip.EnsureTrack( \u0022slide\u0022 );\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( \u0022slide\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tlast.Id,\n\t\t\tselectionTrack.Keys[0].Id,\n\t\t\t\u0022Selected reversal must flip keys around the selected range, not the clip bounds.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tmiddle.Id,\n\t\t\tselectionTrack.Keys[1].Id,\n\t\t\t\u0022Keys outside the reversed selection must retain their frame.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tfirst.Id,\n\t\t\tselectionTrack.Keys[2].Id,\n\t\t\t\u0022Selected reversal must preserve key identities and selection.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tselectionController.SelectedKeys.ToHashSet().SetEquals( [first.Id, last.Id] ),\n\t\t\t\u0022Reversed keys must remain selected for immediate follow-up editing.\u0022 );\n\t}\n\n\tprivate static void TestTimelinePlayback( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Timeline playback\u0022 );\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, \u0022Timeline seeking must reject fractional-frame positions.\u0022 );\n\t\tcontroller.SetTimelineTime( 0.02f );\n\t\tNear( report, 1f / 30, document.Workspace.TimelineTime, 0.0001f, \u0022Timeline seeking must snap to the nearest whole frame.\u0022 );\n\t\tcontroller.JumpToLastFrame();\n\t\tcontroller.TogglePlayback();\n\t\tCheck( report, controller.IsPlaying, \u0022Play must enter the shared playback state.\u0022 );\n\t\tNear( report, 0, document.Workspace.TimelineTime, 0.0001f, \u0022Playing from the last frame must restart at frame zero.\u0022 );\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\u0022Playback must advance through whole-frame preview positions.\u0022 );\n\t\tvar movingTrack = clip.EnsureTrack( \u0022weapon_root\u0022 );\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\u003CGuid, float\u003E { [movingKey.Id] = movingKey.Time },\n\t\t\t1 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.IsPlaying,\n\t\t\t\u0022Selecting and dragging a key must not pause viewport playback.\u0022 );\n\t\tcontroller.EndSelectedKeyMove(\n\t\t\tnew Dictionary\u003CGuid, float\u003E { [movingKey.Id] = 0.2f },\n\t\t\t1 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tcontroller.IsPlaying,\n\t\t\t\u0022Committing a key drag must leave playback running for live SampleTrack checks.\u0022 );\n\t\tcontroller.StepTimelineFrame( 1 );\n\t\tCheck( report, !controller.IsPlaying, \u0022Manual frame stepping must pause playback.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tTimelineInteraction.TimeToFrame( document.Workspace.TimelineTime, 30 ),\n\t\t\t\u0022Next-frame controls must advance exactly one frame.\u0022 );\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\u0022Frame stepping must clamp at the final frame.\u0022 );\n\t\tcontroller.ToggleSelectedClipLoop();\n\t\tCheck(\n\t\t\treport,\n\t\t\tclip.Loop,\n\t\t\t\u0022The selected clip loop state must be editable through the shared controller.\u0022 );\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\u0026\u0026 TimelineInteraction.TimeToFrame(\n\t\t\t\t\tdocument.Workspace.TimelineTime,\n\t\t\t\t\tclip.SampleRate ) == 3,\n\t\t\t\u0022Looped playback must wrap and remain active.\u0022 );\n\t\tcontroller.Undo();\n\t\tCheck(\n\t\t\treport,\n\t\t\t!controller.Document.GetSelectedClip()!.Loop,\n\t\t\t\u0022Changing the loop state must be one undoable action.\u0022 );\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, \u0022An in-range hand target must be reachable.\u0022 );\n\t\tNear( report, new Vector3( 1.5f, 0.4f, 0 ), reachable.End, 0.001f, \u0022Reachable target must be solved exactly.\u0022 );\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, \u0022An overextended target must be reported.\u0022 );\n\t\tCheck( report, clamped.SolvedDistance \u003C 2, \u0022Overextension must clamp below total arm length.\u0022 );\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( \u0022root\u0022, \u0022\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022arm_upper_R\u0022, \u0022root\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022arm_lower_R\u0022, \u0022arm_upper_R\u0022, new Vector3( 1, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022hand_R\u0022, \u0022arm_lower_R\u0022, new Vector3( 2, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022bolt\u0022, \u0022root\u0022, 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 = \u0022@primary_hand\u0022,\n\t\t\tTargetBone = \u0022bolt\u0022,\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[\u0022hand_R\u0022].Position, 0.002f, \u0022Constraint must drive the IK target before the arm solve.\u0022 );\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( \u0022root\u0022, \u0022\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022arm_upper_R\u0022, \u0022root\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022arm_lower_R\u0022, \u0022arm_upper_R\u0022, new Vector3( 1, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022hand_R\u0022, \u0022arm_lower_R\u0022, new Vector3( 2, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022finger_R\u0022, \u0022hand_R\u0022, new Vector3( 2.5f, 0.2f, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022forearm_twist_R\u0022, \u0022arm_lower_R\u0022, 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[\u0022finger_R\u0022] );\n\t\tvar twistLocal = skeleton.GetBindLocal( skeleton.ByName[\u0022forearm_twist_R\u0022] );\n\t\tNear(\n\t\t\treport,\n\t\t\tpose.Model[\u0022hand_R\u0022].PointToWorld( fingerLocal.Position ),\n\t\t\tpose.Model[\u0022finger_R\u0022].Position,\n\t\t\t0.001f,\n\t\t\t\u0022Finger descendants must follow the solved hand.\u0022 );\n\t\tNear(\n\t\t\treport,\n\t\t\tpose.Model[\u0022arm_lower_R\u0022].PointToWorld( twistLocal.Position ),\n\t\t\tpose.Model[\u0022forearm_twist_R\u0022].Position,\n\t\t\t0.001f,\n\t\t\t\u0022Twist descendants must follow the solved forearm.\u0022 );\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( \u0022root\u0022, \u0022\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022arm_upper_R\u0022, \u0022root\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022arm_lower_R\u0022, \u0022arm_upper_R\u0022, new Vector3( 1, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022hand_R\u0022, \u0022arm_lower_R\u0022, new Vector3( 2, 0, 0 ) ) );\n\t\tskeleton.Add( Bone( \u0022bolt\u0022, \u0022root\u0022, new Vector3( 1, 0, 0 ) ) );\n\n\t\tvar clip = document.EnsureClip( WeaponClipRole.Idle );\n\t\tvar boltTrack = clip.EnsureTrack( \u0022bolt\u0022 );\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 = \u0022@primary_hand\u0022,\n\t\t\tTargetBone = \u0022bolt\u0022,\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[\u0022hand_R\u0022].Position, 0.002f, \u0022Maintain-offset constraints must preserve the start-frame hand offset.\u0022 );\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\u0022An unchanged document must reuse the cached host skeleton.\u0022 );\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\u0022A sub-precision calibration change must still invalidate the cached skeleton.\u0022 );\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\u0022A bone bind change must invalidate the cached skeleton.\u0022 );\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\u0022A hand binding change must invalidate the cached skeleton.\u0022 );\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\u0022Rebuilding after a change must repopulate the cache rather than rebuild every call.\u0022 );\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( \u0022History\u0022 ) );\n\t\tcontroller.Mutate( \u0022Rename\u0022, document =\u003E document.Name = \u0022Changed\u0022 );\n\t\tCheck( report, controller.IsDirty \u0026\u0026 controller.CanUndo, \u0022A mutation must mark the document dirty and create undo history.\u0022 );\n\t\tcontroller.Undo();\n\t\tEqual( report, \u0022History\u0022, controller.Document.Name, \u0022Undo must restore the previous snapshot.\u0022 );\n\t\tcontroller.Redo();\n\t\tEqual( report, \u0022Changed\u0022, controller.Document.Name, \u0022Redo must restore the changed snapshot.\u0022 );\n\t\tvar documentEvents = 0;\n\t\tvar poseEvents = 0;\n\t\tvar selectionEvents = 0;\n\t\tvar keySelectionEvents = 0;\n\t\tcontroller.DocumentChanged \u002B= () =\u003E documentEvents\u002B\u002B;\n\t\tcontroller.PoseChanged \u002B= () =\u003E poseEvents\u002B\u002B;\n\t\tcontroller.SelectionChanged \u002B= () =\u003E selectionEvents\u002B\u002B;\n\t\tcontroller.KeySelectionChanged \u002B= () =\u003E keySelectionEvents\u002B\u002B;\n\t\tcontroller.BeginContinuousEdit( \u0022Scrub name\u0022 );\n\t\tcontroller.UpdateContinuousEdit( document =\u003E document.Name = \u0022Scrub A\u0022 );\n\t\tcontroller.UpdateContinuousEdit( document =\u003E document.Name = \u0022Scrub B\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t0,\n\t\t\tdocumentEvents,\n\t\t\t\u0022A live scrub must not broadcast full document rebuilds while dragging.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tposeEvents,\n\t\t\t\u0022A live scrub must publish lightweight pose previews.\u0022 );\n\t\tcontroller.EndContinuousEdit();\n\t\tEqual(\n\t\t\treport,\n\t\t\t1,\n\t\t\tdocumentEvents,\n\t\t\t\u0022Completing a scrub must publish one consolidated document change.\u0022 );\n\t\tcontroller.Undo();\n\t\tEqual( report, \u0022Changed\u0022, controller.Document.Name, \u0022A continuous drag must collapse into one undo step.\u0022 );\n\t\tcontroller.Redo();\n\t\tEqual( report, \u0022Scrub B\u0022, controller.Document.Name, \u0022Redo must restore the final continuous-drag value.\u0022 );\n\n\t\tvar clip = controller.Document.GetSelectedClip()!;\n\t\tvar track = clip.EnsureTrack( \u0022weapon_root\u0022 );\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\u0022Key selection must not broadcast a control-selection rebuild.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tkeySelectionEvents \u003E 0,\n\t\t\t\u0022Key selection must publish its dedicated lightweight event.\u0022 );\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( \u0022weapon_root\u0022 ).Keys.Count, \u0022Pasting keys must duplicate the clipboard payload.\u0022 );\n\t\tNear(\n\t\t\treport,\n\t\t\t0.5f,\n\t\t\tclip.EnsureTrack( \u0022weapon_root\u0022 ).Keys.Max( x =\u003E x.Time ),\n\t\t\t0.0001f,\n\t\t\t\u0022Pasted keys must be offset to the playhead.\u0022 );\n\n\t\tvar keyController = new WeaponAnimatorController();\n\t\tvar keyDocument = ValidDocument();\n\t\tkeyController.SetDocument( keyDocument );\n\t\tkeyController.SelectBone( \u0022weapon_root\u0022 );\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 =\u003E current.Target == \u0022weapon_root\u0022 )\n\t\t\t\t.Keys.Any( current =\u003E MathF.Abs( current.Time - 0.5f ) \u003C 0.0001f ),\n\t\t\t\u0022The shared K/Add Key command must key a selected weapon bone.\u0022 );\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, \u0022A complete calibration should pass.\u0022 );\n\t\tCheck( report, WeaponAnimationValidator.ValidateForGeneration( document ).IsValid, \u0022Idle-only generation should pass with action warnings.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationValidator.ValidateForGeneration( document ).Issues.Any( x =\u003E\n\t\t\t\tx.Severity == ValidationSeverity.Warning \u0026\u0026 x.Code == \u0022clip.fallback\u0022 ),\n\t\t\t\u0022Missing action clips must remain warnings.\u0022 );\n\t\tdocument.Source.SourcePath = \u0022weapons/test/source.smd\u0022;\n\t\tvar smdValidation = WeaponAnimationValidator.ValidateForGeneration( document );\n\t\tCheck(\n\t\t\treport,\n\t\t\tsmdValidation.Issues.Any( issue =\u003E\n\t\t\t\tissue.Blocking \u0026\u0026 issue.Code == \u0022source.not_embeddable\u0022 ),\n\t\t\t\u0022SMD projects must explain the ModelDoc generation limitation before Generate runs.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tsmdValidation.Issues.Any( issue =\u003E\n\t\t\t\tissue.Code == \u0022source.not_embeddable\u0022\n\t\t\t\t\u0026\u0026 issue.Message.Contains( \u0022SMD\u0022, StringComparison.Ordinal ) ),\n\t\t\t\u0022The generation-format diagnostic must name the unsupported source extension.\u0022 );\n\t\tdocument.Source.SourcePath = \u0022weapons/test/source.vmdl\u0022;\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponAnimationValidator.ValidateForGeneration( document ).Issues.All( issue =\u003E\n\t\t\t\tissue.Code != \u0022source.not_embeddable\u0022 ),\n\t\t\t\u0022VMDL projects must pass source-format validation through the generated adapter path.\u0022 );\n\t\tdocument.Source.SourcePath = \u0022weapons/test/source.fbx\u0022;\n\t\tdocument.Calibration.Anchors.RemoveAll( anchor =\u003E\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\u0022Auto-align markers must not block an already-oriented weapon.\u0022 );\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 =\u003E\n\t\t\t\tissue.Code == \u0022scale.implausible\u0022 ),\n\t\t\t\u0022Implausible-scale validation must use persisted source bounds without requiring a measurement.\u0022 );\n\n\t\tdocument.Rig.Bones.Add( new WeaponBoneDefinition { Name = \u0022hand_R\u0022 } );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\t!WeaponAnimationValidator.ValidateCalibration( document ).IsValid,\n\t\t\t\t\u0022Facepunch-reserved weapon bone names must block calibration.\u0022 );\n\n\t\t\tdocument.Rig.Bones.RemoveAt( document.Rig.Bones.Count - 1 );\n\t\t\tdocument.Rig.Bones[0].Name = \u0022root\u0022;\n\t\t\tdocument.Rig.Bones[0].Classification = WeaponBoneClassification.WeaponRoot;\n\t\t\tdocument.Rig.RootBone = \u0022root\u0022;\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tWeaponAnimationValidator.ValidateCalibration( document ).IsValid,\n\t\t\t\t\u0022A classified source root may use a reserved name before wrapper normalization.\u0022 );\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$\u0022weaponanim-output-{Guid.NewGuid():N}\u0022,\n\t\t\t\u0022Assets\u0022 );\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Output Test\u0022 );\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\u0022weapons\u0022,\n\t\t\t\t\u0022output_test\u0022,\n\t\t\t\t\u0022viewmodel\u0022 ) ),\n\t\t\tdefaultOutput,\n\t\t\t\u0022Default generation output must resolve beneath Assets even before the folder exists.\u0022 );\n\n\t\tdocument.Output.OutputFolder = \u0022/weapons/custom/viewmodel\u0022;\n\t\tEqual(\n\t\t\treport,\n\t\t\tPath.GetFullPath( Path.Combine(\n\t\t\t\tcontentRoot,\n\t\t\t\t\u0022weapons\u0022,\n\t\t\t\t\u0022custom\u0022,\n\t\t\t\t\u0022viewmodel\u0022 ) ),\n\t\t\tAssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),\n\t\t\t\u0022A leading asset slash must remain a project-relative output path.\u0022 );\n\n\t\tdocument.Output.OutputFolder = \u0022../outside\u0022;\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\u0022Generation output must reject paths that escape the project\u0027s Assets folder.\u0022 );\n\n\t\tdocument.Output.OutputFolder = \u0022C:/outside\u0022;\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\u0022Generation output must reject absolute drive paths on every host platform.\u0022 );\n\n\t\tvar nestedOutputRoot = Path.Combine(\n\t\t\tPath.GetTempPath(),\n\t\t\t$\u0022weaponanim-nested-output-{Guid.NewGuid():N}\u0022 );\n\t\ttry\n\t\t{\n\t\t\tAssetGenerationService.WriteTextSourcesForTests(\n\t\t\t\tnestedOutputRoot,\n\t\t\t\tnew Dictionary\u003Cstring, string\u003E\n\t\t\t\t{\n\t\t\t\t\t[\u0022materials/output_test_body.vmat\u0022] = \u0022fixture material\u0022,\n\t\t\t\t\t[\u0022output_test_vm.vmdl\u0022] = \u0022fixture model\u0022\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\u0022materials\u0022,\n\t\t\t\t\t\u0022output_test_body.vmat\u0022 ) ),\n\t\t\t\t\u0022Generation must create parent directories for nested material sources.\u0022 );\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\u0022weapons\u0022,\n\t\t\t\t\u0022output_test\u0022,\n\t\t\t\t\u0022viewmodel\u0022 ) ),\n\t\t\tAssetGenerationService.ResolveOutputRootForContentRoot( document, contentRoot ),\n\t\t\t\u0022Generation must repair missing output settings instead of throwing.\u0022 );\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$\u0022weaponanim-removal-{Guid.NewGuid():N}\u0022 );\n\t\tDirectory.CreateDirectory( root );\n\t\ttry\n\t\t{\n\t\t\tvar host = Path.Combine( root, \u0022weapon_host.vmdl\u0022 );\n\t\t\tvar clip = Path.Combine( root, \u0022weapon_idle.dmx\u0022 );\n\t\t\tvar graph = Path.Combine( root, \u0022weapon.vanmgrph\u0022 );\n\t\t\tvar prefab = Path.Combine( root, \u0022v_weapon.prefab\u0022 );\n\t\t\tforeach ( var file in new[] { host, clip, graph, prefab } )\n\t\t\t{\n\t\t\t\tFile.WriteAllText( file, \u0022generated\u0022 );\n\t\t\t\tFile.WriteAllText( $\u0022{file}_c\u0022, \u0022compiled\u0022 );\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 ) \u0026\u0026 !File.Exists( $\u0022{host}_c\u0022 ),\n\t\t\t\t\u0022Removing a generated asset must take its compiled artifact with it.\u0022 );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\t!File.Exists( clip ) \u0026\u0026 !File.Exists( graph ) \u0026\u0026 !File.Exists( prefab ),\n\t\t\t\t\u0022Every listed generated file must be removed.\u0022 );\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, \u0022generated\u0022 );\n\t\t\tFile.WriteAllText( clip, \u0022generated\u0022 );\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\u0022Compiled dependants must be removed before the sources they consume.\u0022 );\n\t\t\t\tvar lifecycleFiles = new[]\n\t\t\t\t{\n\t\t\t\t\t\u0022weapon_sequence_idle.dmx\u0022,\n\t\t\t\t\t\u0022weapon_source_adapter.vmdl\u0022,\n\t\t\t\t\t\u0022weapon_vm_bootstrap.vmdl\u0022,\n\t\t\t\t\t\u0022weapon.vanmgrph\u0022,\n\t\t\t\t\u0022weapon_vm.vmdl\u0022,\n\t\t\t\t\u0022v_weapon.prefab\u0022\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( \u0022|\u0022, lifecycleFiles ),\n\t\t\t\tstring.Join( \u0022|\u0022, writeOrder ),\n\t\t\t\t\u0022Generated sources must appear in dependency order so automatic compilation never observes a missing preview host.\u0022 );\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\u0022v_weapon.prefab|weapon_vm.vmdl|weapon.vanmgrph|weapon_vm_bootstrap.vmdl|weapon_source_adapter.vmdl|weapon_sequence_idle.dmx\u0022,\n\t\t\t\tstring.Join( \u0022|\u0022, removeOrder ),\n\t\t\t\t\u0022Generated consumers must be removed in reverse dependency order.\u0022 );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\t!AssetGenerationService.ShouldDeleteCreatedFileOnRollback(\n\t\t\t\t\t\u0022weapon_sprint.dmx\u0022,\n\t\t\t\t\tpreviouslyOwned: true ),\n\t\t\t\t\u0022Rollback must retain a recreated owned DMX dependency needed by an older host.\u0022 );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tAssetGenerationService.ShouldDeleteCreatedFileOnRollback(\n\t\t\t\t\t\u0022weapon_vm.vmdl\u0022,\n\t\t\t\t\tpreviouslyOwned: true )\n\t\t\t\t\u0026\u0026 AssetGenerationService.ShouldDeleteCreatedFileOnRollback(\n\t\t\t\t\t\u0022new_clip.dmx\u0022,\n\t\t\t\t\tpreviouslyOwned: false ),\n\t\t\t\t\u0022Rollback must remove failed compiled consumers and newly introduced dependencies.\u0022 );\n\n\t\t\tvar freshnessSource = Path.Combine( root, \u0022freshness.vmdl\u0022 );\n\t\t\tvar freshnessCompiled = freshnessSource \u002B \u0022_c\u0022;\n\t\t\tFile.WriteAllText( freshnessSource, \u0022source\u0022 );\n\t\t\tFile.WriteAllText( freshnessCompiled, \u0022compiled\u0022 );\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\u0022A newly written compiled artifact must complete generation even while its managed Asset wrapper is stale.\u0022 );\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\u0022An artifact older than its regenerated source must never be accepted as compile success.\u0022 );\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[\u0022HK_P30L\u0022, \u0022cartridge\u0022],\n\t\t\t[\u0022Material\u0022, \u0022H\u0026K_P30L\u0022, \u0022cartridge\u0022, \u0022cartridge_BaseColor\u0022] );\n\t\tCheck(\n\t\t\treport,\n\t\t\tembeddedMaterials.Contains( \u0022H\u0026K_P30L\u0022 )\n\t\t\t\t\u0026\u0026 embeddedMaterials.Contains( \u0022cartridge\u0022 )\n\t\t\t\t\u0026\u0026 embeddedMaterials.Count == 2,\n\t\t\t\u0022Embedded FBX labels must preserve special characters when matching texture-set names.\u0022 );\n\n\t\tvar discovered = WeaponMaterialPipeline.DiscoverForTests(\n\t\t\t[\n\t\t\t\t\u0022H\u0026K_P30L.vmat\u0022,\n\t\t\t\t\u0022cartridge.vmat\u0022,\n\t\t\t\t\u0022materials/error.vmat\u0022\n\t\t\t],\n\t\t\t[\n\t\t\t\t\u0022/fixture/Textures/HK_P30L_BaseColor.png\u0022,\n\t\t\t\t\u0022/fixture/Textures/HK_P30L_Normal_GL.png\u0022,\n\t\t\t\t\u0022/fixture/Textures/HK_P30L_Normal_DX.png\u0022,\n\t\t\t\t\u0022/fixture/Textures/HK_P30L_Roughness.png\u0022,\n\t\t\t\t\u0022/fixture/Textures/HK_P30L_Metallic.png\u0022,\n\t\t\t\t\u0022/fixture/Textures/cartridge_BaseColor.png\u0022,\n\t\t\t\t\u0022/fixture/Textures/cartridge_Normal_DX.png\u0022\n\t\t\t] );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tdiscovered.Count,\n\t\t\t\u0022Nearby texture discovery must retain every FBX material slot.\u0022 );\n\t\tvar pistol = discovered.Single( material =\u003E material.Name == \u0022H\u0026K_P30L\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tpistol.FindTexture( WeaponTextureChannel.Normal )?.AssetPath\n\t\t\t\t.EndsWith( \u0022Normal_GL.png\u0022, StringComparison.OrdinalIgnoreCase ) == true,\n\t\t\t\u0022S\u0026box-compatible OpenGL normal maps must win when both GL and DX variants are available.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tpistol.FindTexture( WeaponTextureChannel.Metalness ) is not null\n\t\t\t\t\u0026\u0026 discovered.Single( material =\u003E material.Name == \u0022cartridge\u0022 )\n\t\t\t\t\t.FindTexture( WeaponTextureChannel.BaseColor ) is not null,\n\t\t\t\u0022Texture sets must be matched independently to their source material names.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdiscovered.All( material =\u003E !material.SourceMaterialPath.Equals(\n\t\t\t\t\u0022materials/error\u0022,\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) ),\n\t\t\t\u0022The compiler error material must never become a generated weapon material slot.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tdiscovered.All( material =\u003E !Path.HasExtension( material.SourceMaterialPath ) ),\n\t\t\t\u0022Stored source material labels must not look like GameResource dependencies.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t\u0022weaponanim_preview_cache/0123456789abcdef\u0022,\n\t\t\tWeaponMaterialPipeline.LegalPreviewRelativeRootForTests(\n\t\t\t\t\u0022/fixture/Assets/.weaponanim-cache/0123456789abcdef\u0022 ),\n\t\t\t\u0022Preview materials must use a legal non-hidden asset namespace.\u0022 );\n\t\tvar originalRevision = WeaponMaterialPipeline.PreviewRevision( discovered );\n\t\tpistol.Textures[0].Sha256 = \u0022changed-image-hash\u0022;\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\u0022A changed texture input must create a new immutable preview revision.\u0022 );\n\t\tpistol.Textures[0].Sha256 = \u0022\u0022;\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\u0022weapons/test_weapon/viewmodel\u0022 );\n\t\tvar material = generated[\u0022materials/test_weapon_h_k_p30l.vmat\u0022];\n\t\tCheck(\n\t\t\treport,\n\t\t\tmaterial.Contains( \u0022F_SPECULAR 1\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 material.Contains( \u0022F_METALNESS_TEXTURE 1\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 material.Contains( \u0022TextureMetalness\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 material.Contains(\n\t\t\t\t\t\u0022test_weapon_h_k_p30l_metalness.png\u0022,\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\u0022Generated weapon VMATs must enable specular and mapped metalness.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgenerated.Keys.Count( path =\u003E path.EndsWith(\n\t\t\t\t\u0022.vtex\u0022,\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) ) == 0\n\t\t\t\t\u0026\u0026 material.Contains(\n\t\t\t\t\t\u0022test_weapon_h_k_p30l_color.png\u0022,\n\t\t\t\t\tStringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 material.Contains(\n\t\t\t\t\t\u0022test_weapon_h_k_p30l_normal.png\u0022,\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\u0022VMATs must reference image inputs directly so S\u0026box can build native generated VTEX resources.\u0022 );\n\t\tdocument.Source.NeedsModelDocWrapper = true;\n\t\tpistol.PreviewMaterialPath =\n\t\t\t\u0022.weaponanim-cache/fixture/materials/h_k_p30l.vmat\u0022;\n\t\tCheck(\n\t\t\treport,\n\t\t\tWeaponMaterialPipeline.RequiresPreviewRefresh( document ),\n\t\t\t\u0022Legacy hidden preview material paths must force a safe material refresh.\u0022 );\n\t\tforeach ( var binding in discovered.Where( binding =\u003E binding.HasUsableTextures ) )\n\t\t{\n\t\t\tbinding.PreviewMaterialPath =\n\t\t\t\t$\u0022weaponanim_preview_cache/fixture/revision/materials/{binding.OutputName}.vmat\u0022;\n\t\t}\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponMaterialPipeline.RequiresPreviewRefresh( document ),\n\t\t\t\u0022Legal compiled preview material paths must not refresh repeatedly.\u0022 );\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\u0022PreviewMaterialPath\u0022,\n\t\t\t\tStringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !serializedDocument.Contains(\n\t\t\t\t\t\u0022H\u0026K_P30L.vmat\u0022,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ),\n\t\t\t\u0022Transient preview VMATs and source slot extensions must stay out of .wepanim serialization.\u0022 );\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 = \u0022cartridge.vmat\u0022,\n\t\t\t\tName = \u0022cartridge\u0022,\n\t\t\t\tOutputName = \u0022cartridge\u0022\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\u0026\u0026 legacyMaterialDocument.Source.Materials[0].SourceMaterialPath\n\t\t\t\t\t.Equals( \u0022cartridge\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Opening an existing project must remove false VMAT dependencies from source slot metadata.\u0022 );\n\n\t\tvar recoveredCandidate = WeaponSourceImporter.SelectRecoveryCandidateForTests(\n\t\t[\n\t\t\tnew(\n\t\t\t\t\u0022/preview/newer-uncompiled/models/source_abc_textured.vmdl\u0022,\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\u0022/preview/legacy/models/source_abc_textured.vmdl\u0022,\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\u0022/preview/versioned/models/source_abc_textured.vmdl\u0022,\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\u0022/preview/versioned/models/source_abc_textured.vmdl\u0022,\n\t\t\trecoveredCandidate,\n\t\t\t\u0022Missing saved source wrappers must recover to a compiled immutable preview revision.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(\n\t\t\t\t\u0022weaponanim_preview_cache/document/source.vmdl\u0022,\n\t\t\t\t\u0022weaponanim_preview_cache/document/source.vmdl\u0022 )\n\t\t\t\t\u0026\u0026 WeaponAnimatorViewport.ShouldRetryMissingSourcePreview(\n\t\t\t\t\t\u0022weaponanim_preview_cache/document/repaired.vmdl\u0022,\n\t\t\t\t\t\u0022weaponanim_preview_cache/document/source.vmdl\u0022 ),\n\t\t\t\u0022A failed source load must not rebuild the private scene every frame, \u0022\n\t\t\t\t\u002B \u0022but a repaired path must trigger one rebuild.\u0022 );\n\n\t\tvar remaps = WeaponMaterialPipeline.OutputRemaps(\n\t\t\tdocument,\n\t\t\t\u0022weapons/test_weapon/viewmodel\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\t2,\n\t\t\tremaps.Count,\n\t\t\t\u0022Final generation must preserve separate material-slot remaps.\u0022 );\n\t\tvar host = ModelDocWriter.WriteHost(\n\t\t\t\u0022host_reference.dmx\u0022,\n\t\t\t[],\n\t\t\t\u0022\u0022,\n\t\t\t[\u0022weapon_root\u0022],\n\t\t\tnew HostWeaponMesh(\n\t\t\t\t\u0022source.fbx\u0022,\n\t\t\t\t\u0022weapon_root\u0022,\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( \u0022use_global_default = false\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !host.Contains( \u0022use_global_default = true\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022from = \\\u0022H\u0026K_P30L.vmat\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022from = \\\u0022cartridge.vmat\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Weapon ModelDocs must use per-slot remaps with global material override disabled.\u0022 );\n\t}\n\n\tprivate static void TestRebase( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault();\n\t\tdocument.Rig.RootBone = \u0022weapon_root\u0022;\n\t\tvar idle = document.EnsureClip( WeaponClipRole.Idle );\n\t\tvar rootTrack = idle.EnsureTrack( \u0022weapon_root\u0022 );\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, \u0022Root keys must retain their placement-relative offset.\u0022 );\n\t}\n\n\tprivate static void TestDmxOutput( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar skeleton = new HostSkeleton();\n\t\tskeleton.Add( Bone( \u0022root\u0022, \u0022\u0022, Vector3.Zero ) );\n\t\tskeleton.Add( Bone( \u0022weapon_root\u0022, \u0022root\u0022, Vector3.Forward ) );\n\t\tvar first = DmxWriter.WriteReference( skeleton );\n\t\tvar second = DmxWriter.WriteReference( skeleton );\n\n\t\tCheck( report, first.StartsWith( \u0022\u003C!-- dmx encoding keyvalues2 4 format model 22 --\u003E\u0022 ), \u0022Host reference must use ModelDoc\u0027s supported DMX model format.\u0022 );\n\t\tCheck( report, first.Contains( \u0022\\\u0022name\\\u0022 \\\u0022string\\\u0022 \\\u0022weapon_root\\\u0022\u0022 ), \u0022Host reference must include every skeleton bone.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfirst.Contains( \u0022\\\u0022element\\\u0022 \\\u0022\u0022 \u002B DmxJointIdForTest( 0 ) \u002B \u0022\\\u0022,\u0022 ),\n\t\t\t\u0022DMX element array entries must be comma-delimited.\u0022 );\n\t\tvar blendIndices = first[first.IndexOf( \u0022\\\u0022blendindices$0\\\u0022 \\\u0022int_array\\\u0022\u0022, StringComparison.Ordinal )..];\n\t\tCheck(\n\t\t\treport,\n\t\t\tblendIndices.Contains( \u0022\\t\\t\\\u00221\\\u0022,\\n\\t\\t\\\u00221\\\u0022,\\n\\t\\t\\\u00221\\\u0022\\n\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022The carrier mesh must reference every host bone so ModelDoc cannot cull the skeleton.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfirst.Contains( \u0022\\t\\t\\t\\t\\\u00223\\\u0022,\\n\\t\\t\\t\\t\\\u00224\\\u0022,\\n\\t\\t\\t\\t\\\u00225\\\u0022,\\n\\t\\t\\t\\t\\\u0022-1\\\u0022\\n\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022The carrier mesh must emit one triangle per host bone.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfirst.Contains( \u0022materials/tools/toolsinvisible.vmat\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022The bone-retention carrier must use an invisible material.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tfirst.Contains( \u0022\\\u0022forwardParity\\\u0022 \\\u0022int\\\u0022 \\\u00221\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !first.Contains( \u0022\\\u0022forwardParity\\\u0022 \\\u0022int\\\u0022 \\\u0022-2\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Reference DMX must use the Source 2 Z-up axis parity expected by ModelDoc.\u0022 );\n\t\tEqual( report, first, second, \u0022DMX host references must be deterministic.\u0022 );\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( \u0022\\\u0022DmeChannelsClip\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 animation.Contains( \u0022\\\u0022DmeVector3LogLayer\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 animation.Contains( \u0022\\\u0022DmeQuaternionLogLayer\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 animation.Contains( \u0022\\\u0022DmeFloatLogLayer\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022DMX animation output must contain position, rotation, and scale channels.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \u0022\\\u0022DmeJoint\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !animation.Contains( \u0022\\\u0022DmeDag\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Animation skeleton entries must be Source 2 joints rather than generic DAG nodes.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \u0022\\\u0022DmeTransformList\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 animation.Contains( \u0022\\\u0022baseStates\\\u0022 \\\u0022element_array\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Animation DMX must include a bind transform list for ModelDoc sequence import.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \u0022\\\u0022mode\\\u0022 \\\u0022int\\\u0022 \\\u00221\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Animation channels must use the Source 2 exporter channel mode.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \u0022\\\u0022jointList\\\u0022 \\\u0022element_array\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 animation.Contains(\n\t\t\t\t\t\u0022\\\u0022element\\\u0022 \\\u0022\u0022 \u002B DmxAnimationJointIdForTest( clip, 0 ) \u002B \u0022\\\u0022\u0022,\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\u0022Animation DMX must register every animated joint with its model.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \u0022\\t\\t\\\u00221\\\u0022\\n\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022A one-second animation must include its final sample time.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!animation.Contains( \u0022NaN\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t\t\u0026\u0026 !animation.Contains( \u0022Infinity\u0022, StringComparison.OrdinalIgnoreCase ),\n\t\t\t\u0022DMX animation output must contain finite transforms.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tanimation.Contains( \u0022\\\u0022forwardParity\\\u0022 \\\u0022int\\\u0022 \\\u00221\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !animation.Contains( \u0022\\\u0022forwardParity\\\u0022 \\\u0022int\\\u0022 \\\u0022-2\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Animation DMX must use the same Source 2 axis system as its host reference.\u0022 );\n\t\tEqual(\n\t\t\treport,\n\t\t\tanimation,\n\t\t\tDmxWriter.WriteAnimation( document, skeleton, clip ),\n\t\t\t\u0022DMX animation output must be deterministic.\u0022 );\n\n\t\tvar scaledSkeleton = new HostSkeleton();\n\t\tscaledSkeleton.Add( new HostBone\n\t\t{\n\t\t\tName = \u0022root\u0022,\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 = \u0022weapon_root\u0022,\n\t\t\tParentName = \u0022root\u0022,\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 = \u0022hammer\u0022,\n\t\t\tParentName = \u0022weapon_root\u0022,\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( \u0022hammer\u0022 );\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[\u0022weapon_root\u0022];\n\t\tvar exportedHammer = exportedPose[\u0022hammer\u0022];\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\u0022Animation export must use ModelDoc\u0027s scale-one compiled bind space.\u0022 );\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\u0022Rotating a weapon child must use the physical scale-baked mesh pivot in compiled bind space.\u0022 );\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\u0022Rotating a weapon child must retain its authored local rotation in compiled bind space.\u0022 );\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\u0022\\\u0022scale\\\u0022 \\\u0022float\\\u0022 \\\u00220.56\\\u0022\u0022,\n\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\u0022Animation bind declarations must not reintroduce source scale after ModelDoc bakes it into the host.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tscaledReference.Contains(\n\t\t\t\t\u0022\\\u0022position\\\u0022 \\\u0022vector3\\\u0022 \\\u00220 -3.22 0.112\\\u0022\u0022,\n\t\t\t\tStringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 scaledAnimation.Contains(\n\t\t\t\t\t\u0022\\\u0022position\\\u0022 \\\u0022vector3\\\u0022 \\\u00220 -3.22 0.112\\\u0022\u0022,\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\u0022Reference and animation skeletons must share the scale-baked physical pivot of rotating weapon children.\u0022 );\n\n\t\tdocument.Manifest.Files.Add( new GeneratedFileRecord\n\t\t{\n\t\t\tRelativePath = \u0022generated_sequence.dmx\u0022\n\t\t} );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!Json.Serialize( document ).Contains( \u0022\\\u0022Manifest\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022The creative document must not serialize generated filenames as resource dependencies.\u0022 );\n\t\tvar wrapper = ModelDocWriter.WriteSourceWrapper( \u0022weapon.fbx\u0022, \u0022root\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\twrapper.Contains( \u0022original_bone_name = \\\u0022root\\\u0022\u0022 )\n\t\t\t\t\u0026\u0026 wrapper.Contains( \u0022new_bone_name = \\\u0022weapon_root\\\u0022\u0022 ),\n\t\t\t\u0022Source wrappers must normalize the selected weapon root.\u0022 );\n\t\tvar host = ModelDocWriter.WriteHost(\n\t\t\t\u0022host_reference.dmx\u0022,\n\t\t\t[],\n\t\t\t\u0022weapon.vanmgrph\u0022,\n\t\t\tskeleton.Bones.Select( bone =\u003E bone.Name ),\n\t\t\tnew HostWeaponMesh(\n\t\t\t\t\u0022weapon.fbx\u0022,\n\t\t\t\t\u0022root\u0022,\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\u0022frame.vmat\u0022,\n\t\t\t\t\t\t\u0022weapons/test/materials/frame.vmat\u0022 )\n\t\t\t\t] ),\n\t\t\t[\n\t\t\t\tnew HostAttachment(\n\t\t\t\t\t\u0022muzzle\u0022,\n\t\t\t\t\t\u0022weapon_root\u0022,\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( \u0022target_bone = \\\u0022weapon_root\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022do_not_discard = true\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022filename = \\\u0022weapon.fbx\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022import_scale = 0.6\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022anim_graph_name = \\\u0022weapon.vanmgrph\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022use_global_default = false\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !host.Contains( \u0022use_global_default = true\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022from = \\\u0022frame.vmat\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022from = \\\u0022materials/tools/toolsinvisible.vmat\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022_class = \\\u0022Attachment\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 host.Contains( \u0022name = \\\u0022muzzle\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Host ModelDocs must preserve generated bones, safely handle imported materials, and contain the visible weapon, graph, and attachments.\u0022 );\n\t\tvar skeletonOnlyHost = ModelDocWriter.WriteHost(\n\t\t\t\u0022host_reference.dmx\u0022,\n\t\t\t[],\n\t\t\t\u0022\u0022,\n\t\t\tskeleton.Bones.Select( bone =\u003E bone.Name ) );\n\t\tCheck(\n\t\t\treport,\n\t\t\tskeletonOnlyHost.Contains( \u0022use_global_default = false\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022A skeleton-only host must retain the invisible carrier material without substitution.\u0022 );\n\t}\n\n\tprivate static void TestFilteredSourceWrapper( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar wrapper = ModelDocWriter.WriteSourceWrapper(\n\t\t\t\u0022weapons/test/source.fbx\u0022,\n\t\t\t\u0022Armature\u0022,\n\t\t\t[\u0022foreign_arm\u0022, \u0022foreign_camera\u0022] );\n\t\tCheck( report, wrapper.Contains( \u0022_class = \\\u0022RenameBone\\\u0022\u0022 ), \u0022A tool-owned source wrapper must normalize the root without modifying the original source.\u0022 );\n\t\tCheck( report, wrapper.Contains( \u0022_class = \\\u0022RemoveBoneAndChildren\\\u0022\u0022 ), \u0022A filtered source wrapper must remove excluded branch roots.\u0022 );\n\t\tCheck( report, wrapper.Contains( \u0022\\\u0022foreign_arm\\\u0022\u0022 ) \u0026\u0026 wrapper.Contains( \u0022\\\u0022foreign_camera\\\u0022\u0022 ), \u0022Every excluded branch root must be emitted deterministically.\u0022 );\n\t\tvar vmdl = $\u0022{ModelDocWriter.Header}\\n{{ rootNode = {{ _class = \\\u0022RootNode\\\u0022 children = [ ] }} }}\u0022;\n\t\tvar adapted = ModelDocWriter.WriteVmdlSourceAdapter( vmdl, \u0022root\u0022, [\u0022foreign_arm\u0022] );\n\t\tCheck(\n\t\t\treport,\n\t\t\tadapted.Contains( \u0022_class = \\\u0022ModelModifierList\\\u0022\u0022 )\n\t\t\t\t\u0026\u0026 adapted.Contains( \u0022original_bone_name = \\\u0022root\\\u0022\u0022 )\n\t\t\t\t\u0026\u0026 adapted.Contains( \u0022\\\u0022foreign_arm\\\u0022\u0022 ),\n\t\t\t\u0022VMDL inputs must receive the same tool-owned root normalization and branch filtering.\u0022 );\n\t}\n\n\tprivate static void TestGenerationSourceAdapters( WeaponAnimatorSelfTestReport report )\n\t{\n\t\tvar source = $$\u0022\u0022\u0022\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 = \u0022RootNode\u0022\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 = \u0022RenderMeshFile\u0022\n\t\t\t\t\t\t\tfilename = \u0022receiver.fbx\u0022\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 = \u0022RenderMeshFile\u0022\n\t\t\t\t\t\t\tfilename = \u0022magazine.fbx\u0022\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\u0022\u0022\u0022;\n\t\tvar adapted = ModelDocWriter.WriteVmdlSourceAdapter(\n\t\t\tsource,\n\t\t\t\u0022root\u0022,\n\t\t\t[\u0022foreign_arm\u0022],\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, \u0022import_scale = 0.5\u0022 ) == 2\n\t\t\t\t\u0026\u0026 adapted.Contains( \u0022import_translation = [ 11, 0, 0 ]\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 adapted.Contains( \u0022import_translation = [ 10, 1, 0 ]\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 adapted.Contains( \u0022original_bone_name = \\\u0022root\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 adapted.Contains( \u0022\\\u0022foreign_arm\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022A VMDL adapter must apply calibration to every render mesh while preserving filtering.\u0022 );\n\n\t\tvar baseHost = ModelDocWriter.WriteHost(\n\t\t\t\u0022reference.dmx\u0022,\n\t\t\t[],\n\t\t\t\u0022\u0022,\n\t\t\t[\u0022weapon_root\u0022],\n\t\t\tbaseModelPath: \u0022weapons/test/source_adapter.vmdl\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tbaseHost.Contains(\n\t\t\t\t\u0022base_model_name = \\\u0022weapons/test/source_adapter.vmdl\\\u0022\u0022,\n\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\u0022Generated hosts must be able to derive their visible mesh from a VMDL adapter.\u0022 );\n\n\t\tvar temporary = Path.Combine(\n\t\t\tPath.GetTempPath(),\n\t\t\t$\u0022weaponanim-source-{Guid.NewGuid():N}.vmdl\u0022 );\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\u003CGenerationProgress\u003E();\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\u0022weapons/test_weapon/viewmodel\u0022,\n\t\t\t\tprogress.Add );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tgenerated.ContainsKey( \u0022test_weapon_source_adapter.vmdl\u0022 )\n\t\t\t\t\t\u0026\u0026 generated[\u0022test_weapon_vm.vmdl\u0022].Contains(\n\t\t\t\t\t\t\u0022base_model_name = \\\u0022weapons/test_weapon/viewmodel/test_weapon_source_adapter.vmdl\\\u0022\u0022,\n\t\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\t\u0022VMDL source projects must generate a persistent calibrated adapter.\u0022 );\n\t\t\tCheck(\n\t\t\t\treport,\n\t\t\t\tprogress.Any( item =\u003E\n\t\t\t\t\titem.Stage == \u0022Sequences\u0022\n\t\t\t\t\t\u0026\u0026 item.Completed == 1\n\t\t\t\t\t\u0026\u0026 item.Total == 1 ),\n\t\t\t\t\u0022Generation must report deterministic per-sequence progress.\u0022 );\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\u0022weapons/test_weapon/viewmodel\u0022,\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\u0022Generation must honor cancellation before assembling or replacing output files.\u0022 );\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\u0022DMX frame sampling must observe cancellation inside the worker-safe generation path.\u0022 );\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( $\u0022SboxWeaponAnimator.DmxReference:joint:{index}\u0022 ) );\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$\u0022SboxWeaponAnimator.DmxReference:animation:{clip.Id}:joint:{index}\u0022;\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( \u0022fr-FR\u0022 );\n\t\t\t\tvar graphFrench = AnimGraphWriter.Write( document, \u0022weapons/test/host.vmdl\u0022 );\n\t\t\t\tvar modelFrench = ModelDocWriter.WriteHost(\n\t\t\t\t\t\u0022host_reference.dmx\u0022,\n\t\t\t\t\t[(idle, \u0022idle.dmx\u0022)],\n\t\t\t\t\t\u0022weapon.vanmgrph\u0022,\n\t\t\t\t\t[\u0022root\u0022, \u0022weapon_root\u0022] );\n\t\t\tvar prefabFrench = PrefabWriter.Write( document, \u0022host.vmdl\u0022 );\n\n\t\t\tCultureInfo.CurrentCulture = CultureInfo.GetCultureInfo( \u0022en-US\u0022 );\n\t\t\tEqual( report, graphFrench, AnimGraphWriter.Write( document, \u0022weapons/test/host.vmdl\u0022 ), \u0022AnimGraph output must be culture-independent.\u0022 );\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\u0022host_reference.dmx\u0022,\n\t\t\t\t\t\t[(idle, \u0022idle.dmx\u0022)],\n\t\t\t\t\t\t\u0022weapon.vanmgrph\u0022,\n\t\t\t\t\t\t[\u0022root\u0022, \u0022weapon_root\u0022] ),\n\t\t\t\t\u0022ModelDoc output must be culture-independent.\u0022 );\n\t\t\tEqual( report, prefabFrench, PrefabWriter.Write( document, \u0022host.vmdl\u0022 ), \u0022Prefab output must be culture-independent.\u0022 );\n\t\t\tEqual( report, AnimGraphWriter.Id( \u0022node:Root\u0022 ), AnimGraphWriter.Id( \u0022node:Root\u0022 ), \u0022Deterministic graph IDs must be stable.\u0022 );\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 = \u0022attack_discouraged\u0022,\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, \u0022host.vmdl\u0022 );\n\t\tCheck( report, graph.Contains( \u0022_class = \\\u0022CAnimTagSpan\\\u0022\u0022 ), \u0022Authored tags must become sequence tag spans.\u0022 );\n\t\tCheck( report, graph.Contains( \u0022m_fStartCycle = 0.2\u0022 ), \u0022Tag start time must be normalized to sequence cycle.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tCount( graph, \u0022m_sequenceName = \\\u0022idle\\\u0022\u0022 ) \u003E 1,\n\t\t\t\u0022Missing action clips must use Idle sequence fallbacks.\u0022 );\n\t\tCheck( report, graph.Contains( \u0022m_name = \\\u0022b_attack\\\u0022\u0022 ), \u0022Facepunch firearm parameters must be exposed.\u0022 );\n\t\tCheck( report, graph.Contains( \u0022m_name = \\\u0022reload_increment\\\u0022\u0022 ), \u0022Standard reload tags must be declared.\u0022 );\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\u0022weapons/test_weapon/viewmodel\u0022 );\n\t\tvar finalHost = generated[\u0022test_weapon_vm.vmdl\u0022];\n\t\tvar bootstrapHost = generated[\u0022test_weapon_vm_bootstrap.vmdl\u0022];\n\t\tvar generatedGraph = generated[\u0022test_weapon.vanmgrph\u0022];\n\t\tCheck(\n\t\t\treport,\n\t\t\tfinalHost.Contains(\n\t\t\t\t\u0022anim_graph_name = \\\u0022weapons/test_weapon/viewmodel/test_weapon.vanmgrph\\\u0022\u0022,\n\t\t\t\tStringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 bootstrapHost.Contains( \u0022anim_graph_name = \\\u0022\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 generatedGraph.Contains(\n\t\t\t\t\t\u0022m_previewModels = [ \\\u0022weapons/test_weapon/viewmodel/test_weapon_vm_bootstrap.vmdl\\\u0022, ]\u0022,\n\t\t\t\t\tStringComparison.Ordinal ),\n\t\t\t\u0022Generation must keep a permanent graph-free preview host while the final host always links its AnimGraph.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgenerated.ContainsKey( \u0022test_weapon_sequence_idle.dmx\u0022 )\n\t\t\t\t\u0026\u0026 !generated.ContainsKey( \u0022test_weapon_idle.dmx\u0022 )\n\t\t\t\t\u0026\u0026 !generated.ContainsKey( \u0022test_weapon_sequence_fire.dmx\u0022 ),\n\t\t\t\u0022Generation must emit authored sequences only and leave missing action roles on Idle fallbacks.\u0022 );\n\n\t\tvar custom = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\tcustom.Name = \u0022Mechanical Check\u0022;\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\u0022weapons/test_weapon/viewmodel\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgenerated.ContainsKey(\n\t\t\t\t$\u0022test_weapon_sequence_{custom.GeneratedSequenceName}.dmx\u0022 ),\n\t\t\t\u0022Authored custom clips must use their persisted readable sequence name.\u0022 );\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 = \u0022Spare Magazine\u0022,\n\t\t\tBoneId = \u0022weapon_root\u0022,\n\t\t\tBoneName = \u0022weapon_root\u0022,\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\u0026\u0026 WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f )\n\t\t\t\t\u0026\u0026 !WeaponVisibilityEvaluator.Evaluate( part, idle, 0.9f ),\n\t\t\t\u0022Visibility tracks must evaluate as stepped state changes from the configured default.\u0022 );\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\u0022Keying visibility twice at one frame must update the existing key.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!WeaponVisibilityEvaluator.Evaluate( part, idle, 0.5f ),\n\t\t\t\u0022A replaced visibility key must take effect immediately.\u0022 );\n\t\treplacement.Visible = true;\n\n\t\tvar spans = WeaponVisibilityEvaluator.BuildSpans( part, idle );\n\t\tEqual( report, 3, spans.Count, \u0022Visibility export must cover the full clip with deterministic state spans.\u0022 );\n\t\tNear( report, 0, spans[0].StartTime, 0.0001f, \u0022The first visibility span must begin at clip start.\u0022 );\n\t\tNear( report, idle.Duration, spans[^1].EndTime, 0.0001f, \u0022The final visibility span must reach clip end.\u0022 );\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, \u0022host.vmdl\u0022 );\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\u0022Visibility export must be deterministic and must not mutate authored transforms.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tbefore.Contains( \u0022\\\u0022DmeFloatLogLayer\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 before.Contains( \u0022\\\u00220.0001\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 before.Contains( \u0022-8192\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Bone visibility must use native sequence scale and off-screen position channels.\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgraph.Contains( WeaponVisibilityEvaluator.VisibleTag( part.Id ) )\n\t\t\t\t\u0026\u0026 graph.Contains( WeaponVisibilityEvaluator.HiddenTag( part.Id ) ),\n\t\t\t\u0022Generated AnimGraphs must declare both visibility states for every part.\u0022 );\n\n\t\tvar prefab = PrefabWriter.Write( document, \u0022host.vmdl\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\t!prefab.Contains( \u0022WeaponPartVisibilityController\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !prefab.Contains( \u0022\\\u0022Name\\\u0022: \\\u0022source_weapon\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 prefab.Contains( \u0022\\\u0022Model\\\u0022: \\\u0022host.vmdl\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 prefab.Contains( \u0022\\\u0022GameLayer\\\u0022: true\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 Count( prefab, \u0022\\\u0022__type\\\u0022: \\\u0022Sandbox.SkinnedModelRenderer\\\u0022\u0022 ) == 2\n\t\t\t\t\u0026\u0026 prefab.Contains( \u0022\\\u0022Name\\\u0022: \\\u0022muzzle\\\u0022\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 prefab.Contains( \u0022\\\u0022Name\\\u0022: \\\u0022eject\\\u0022\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Generated prefabs must use one visible host renderer plus bone-merged arms and explicit output anchors, with no custom controller.\u0022 );\n\t\tdocument.Output.GenerateGraph = false;\n\t\tvar graphFreePrefab = PrefabWriter.Write( document, \u0022host.vmdl\u0022 );\n\t\tCheck(\n\t\t\treport,\n\t\t\tgraphFreePrefab.Contains( \u0022\\\u0022UseAnimGraph\\\u0022: false\u0022, StringComparison.Ordinal )\n\t\t\t\t\u0026\u0026 !graphFreePrefab.Contains( \u0022WeaponPartVisibilityController\u0022, StringComparison.Ordinal ),\n\t\t\t\u0022Graph-free prefabs must remain standard and disable AnimGraph playback.\u0022 );\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 =\u003E x.PartId == part.Id )\n\t\t\t\t.Keys.Any( x =\u003E MathF.Abs( x.Time - 0.5f ) \u003C= 0.0001f ),\n\t\t\t\u0022Visibility keys must participate in the shared copy and paste workflow.\u0022 );\n\n\t\tpart.RenderMode = VisibilityRenderMode.BodyGroup;\n\t\tpart.BodyGroupName = \u0022\u0022;\n\t\tvar invalid = WeaponAnimationValidator.ValidateForGeneration( document );\n\t\tCheck(\n\t\t\treport,\n\t\t\tinvalid.Issues.Any( x =\u003E x.Code == \u0022visibility.bodygroup_missing\u0022 )\n\t\t\t\t\u0026\u0026 invalid.Issues.Any( x =\u003E x.Code == \u0022visibility.bodygroup_export\u0022 ),\n\t\t\t\u0022Generation validation must reject bodygroup visibility until it can be baked into a standard prefab.\u0022 );\n\t}\n\n\tprivate static WeaponAnimationDocument ValidDocument()\n\t{\n\t\tvar document = WeaponAnimationDocument.CreateDefault( \u0022Test Weapon\u0022 );\n\t\tdocument.Source.SourcePath = \u0022weapons/test/source.fbx\u0022;\n\t\tdocument.Source.CompiledModelPath = \u0022weapons/test/source.vmdl\u0022;\n\t\tdocument.Source.Compiled = true;\n\t\tdocument.Source.PreviewHostCompiled = true;\n\t\tdocument.Rig.RootBone = \u0022weapon_root\u0022;\n\t\tdocument.Rig.Bones.Add( new WeaponBoneDefinition\n\t\t{\n\t\t\tId = \u0022weapon_root\u0022,\n\t\t\tHierarchyPath = \u0022weapon_root\u0022,\n\t\t\tName = \u0022weapon_root\u0022,\n\t\t\tOriginalName = \u0022weapon_root\u0022,\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 = \u0022weapon_root\u0022;\n\t\tdocument.Rig.WeaponSubtreeRootId = \u0022weapon_root\u0022;\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 ) =\u003E new()\n\t{\n\t\tName = kind.ToString(),\n\t\tKind = kind,\n\t\tBoneName = \u0022weapon_root\u0022,\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 ) =\u003E\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 ) =\u003E 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 ) =\u003E 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 ) =\u003E\n\t\tMathF.Sqrt( value.x * value.x \u002B value.y * value.y \u002B value.z * value.z \u002B 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 )) \u003E= 0 )\n\t\t{\n\t\t\tcount\u002B\u002B;\n\t\t\toffset \u002B= 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\u003CWeaponAnimatorSelfTestReport\u003E 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( $\u0022{name}: threw {ex.GetType().Name}: {ex.Message}\u0022 );\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\u002B\u002B;\n\t\telse\n\t\t\treport.Failures.Add( message );\n\t}\n\n\tprivate static void Equal\u003CT\u003E(\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\u003CT\u003E.Default.Equals( expected, actual ),\n\t\t\t$\u0022{message} Expected \u0027{expected}\u0027, got \u0027{actual}\u0027.\u0022 );\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 ) \u003C= tolerance,\n\t\t\t$\u0022{message} Expected {expected}, got {actual}.\u0022 );\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 ) \u003C= tolerance,\n\t\t\t$\u0022{message} Expected {expected}, got {actual}.\u0022 );\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( \u0022wepanim\u0022 )]\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 =\u003E false;\n\tpublic void SelectMember( string memberName ) { }\n\n\tpublic WeaponAnimatorWindow()\n\t{\n\t\tDeleteOnClose = true;\n\t\tWindowTitle = \u0022S\u0026box Weapon Animator\u0022;\n\t\tTitle = WindowTitle;\n\t\tSize = new Vector2( 1600, 940 );\n\t\tMinimumSize = new Vector2( 1200, 720 );\n\t\tStateCookie = \u0022SboxWeaponAnimator.Window\u0022;\n\t\tSetWindowIcon( \u0022animation\u0022 );\n\n\t\t_controller.DocumentChanged \u002B= OnDocumentChanged;\n\t\t_controller.DirtyChanged \u002B= OnDirtyChanged;\n\t\t_controller.PlaybackChanged \u002B= 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\u003CWeaponAnimationAsset\u003E() ?? 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\u0026\u0026 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\u0022Cancelling asset generation before closing\u2026\u0022,\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() =\u003E\n\t\t\t{\n\t\t\t\tif ( Save() )\n\t\t\t\tCloseAfterPrompt();\n\t\t\t},\n\t\t\t() =\u003E\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() =\u003E CloseAfterPrompt( clearRecovery: false ),\n\t\t\t\t\t\u0022Discard all unsaved changes to this Weapon Animation Project?\u0022,\n\t\t\t\t\t\u0022Discard Changes\u0022,\n\t\t\t\t\t\u0022Discard\u0022,\n\t\t\t\t\t\u0022Cancel\u0022 );\n\t\t\t},\n\t\t\t\u0022Save changes before closing this Weapon Animation Project?\u0022,\n\t\t\t\u0022Unsaved Weapon Animation Project\u0022,\n\t\t\t\u0022Save\u0022,\n\t\t\t\u0022More Options\u0022 );\n\t\treturn false;\n\t}\n\n\t[Shortcut( \u0022editor.save\u0022, \u0022Ctrl\u002BS\u0022, ShortcutType.Window )]\n\tprivate void ShortcutSave() =\u003E Save();\n\n\t[Shortcut( \u0022editor.undo\u0022, \u0022Ctrl\u002BZ\u0022, ShortcutType.Window )]\n\tprivate void ShortcutUndo() =\u003E _controller.Undo();\n\n\t[Shortcut( \u0022editor.redo\u0022, \u0022Ctrl\u002BY\u0022, ShortcutType.Window )]\n\tprivate void ShortcutRedo() =\u003E _controller.Redo();\n\n\t[Shortcut( \u0022weaponanim.copykeys\u0022, \u0022Ctrl\u002BC\u0022, ShortcutType.Window )]\n\tprivate void ShortcutCopy() =\u003E _controller.CopySelectedKeys();\n\n\t[Shortcut( \u0022weaponanim.pastekeys\u0022, \u0022Ctrl\u002BV\u0022, ShortcutType.Window )]\n\tprivate void ShortcutPaste() =\u003E _controller.PasteKeys();\n\n\t[Shortcut( \u0022weaponanim.cutkeys\u0022, \u0022Ctrl\u002BX\u0022, ShortcutType.Window )]\n\tprivate void ShortcutCut() =\u003E _controller.CutSelectedKeys();\n\n\t[Shortcut( \u0022weaponanim.key\u0022, \u0022K\u0022, ShortcutType.Window )]\n\tprivate void ShortcutKey() =\u003E _controller.KeySelectedTransform();\n\n\t[Shortcut( \u0022weaponanim.move\u0022, \u0022W\u0022, 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( \u0022weaponanim.rotate\u0022, \u0022E\u0022, ShortcutType.Window )]\n\tprivate void ShortcutRotate() =\u003E\n\t\t_viewport?.SetTransformMode( WeaponAnimatorTransformMode.Rotate );\n\n\t[Shortcut( \u0022weaponanim.scale\u0022, \u0022R\u0022, ShortcutType.Window )]\n\tprivate void ShortcutScale() =\u003E\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 = \u0022\u0022;\n\t\t_controller.Mutate(\n\t\t\t\u0022Recover missing source preview\u0022,\n\t\t\tdocument =\u003E 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( \u0022File\u0022 );\n\t\tfile.AddOption( \u0022New\u0022, \u0022note_add\u0022, WeaponAnimatorLauncher.CreateNew );\n\t\tfile.AddOption( \u0022Open\u2026\u0022, \u0022folder_open\u0022, WeaponAnimatorLauncher.OpenExisting );\n\t\tfile.AddSeparator();\n\t\tfile.AddOption( \u0022Save\u0022, \u0022save\u0022, () =\u003E Save(), \u0022editor.save\u0022 );\n\t\tfile.AddOption( \u0022Save As\u2026\u0022, \u0022save_as\u0022, SaveAs );\n\t\tfile.AddOption( \u0022Generate Assets\u0022, \u0022build\u0022, GenerateAssets );\n\t\tfile.AddSeparator();\n\t\tfile.AddOption( \u0022Close\u0022, \u0022close\u0022, Close );\n\n\t\tvar edit = MenuBar.AddMenu( \u0022Edit\u0022 );\n\t\tedit.AddOption( \u0022Undo\u0022, \u0022undo\u0022, _controller.Undo, \u0022editor.undo\u0022 );\n\t\tedit.AddOption( \u0022Redo\u0022, \u0022redo\u0022, _controller.Redo, \u0022editor.redo\u0022 );\n\t\tedit.AddSeparator();\n\t\tedit.AddOption( \u0022Cut Keys\u0022, \u0022content_cut\u0022, _controller.CutSelectedKeys );\n\t\tedit.AddOption( \u0022Copy Keys\u0022, \u0022content_copy\u0022, _controller.CopySelectedKeys );\n\t\tedit.AddOption( \u0022Paste Keys\u0022, \u0022content_paste\u0022, _controller.PasteKeys );\n\t\tedit.AddOption( \u0022Delete Keys\u0022, \u0022delete\u0022, _controller.DeleteSelectedKeys );\n\t\tedit.AddSeparator();\n\t\tedit.AddOption( \u0022Preferences\u2026\u0022, \u0022tune\u0022, OpenPreferences );\n\n\t\tvar view = MenuBar.AddMenu( \u0022View\u0022 );\n\t\tview.AddOption( \u0022Calibrate\u0022, \u0022straighten\u0022, RequestCalibrationStage );\n\t\tview.AddOption( \u0022Animate\u0022, \u0022animation\u0022, () =\u003E SwitchStage( WeaponAnimatorStage.Animate ) );\n\t\tview.AddSeparator();\n\t\tvar guides = view.AddOption( \u0022Toggle Guides\u0022, \u0022aspect_ratio\u0022, () =\u003E\n\t\t\t_controller.Mutate( \u0022Viewport guides\u0022, d =\u003E d.Workspace.ShowGuides = !d.Workspace.ShowGuides ) );\n\t\tBindCheckedState( guides, () =\u003E _controller.Document.Workspace.ShowGuides );\n\t\tvar skeleton = view.AddOption( \u0022Toggle Skeleton\u0022, \u0022accessibility_new\u0022, () =\u003E\n\t\t\t_controller.Mutate( \u0022Skeleton overlay\u0022, d =\u003E d.Workspace.ShowSkeleton = !d.Workspace.ShowSkeleton ) );\n\t\tBindCheckedState( skeleton, () =\u003E _controller.Document.Workspace.ShowSkeleton );\n\t\tvar xray = view.AddOption( \u0022X-Ray Skeleton\u0022, \u0022visibility\u0022, () =\u003E\n\t\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\t\u0022X-ray skeleton\u0022,\n\t\t\t\tworkspace =\u003E workspace.XRaySkeleton = !workspace.XRaySkeleton ) );\n\t\tBindCheckedState( xray, () =\u003E _controller.Document.Workspace.XRaySkeleton );\n\t\tvar boneOcclusion = view.AddOption( \u0022Bone Occlusion\u0022, \u0022gradient\u0022, () =\u003E\n\t\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\t\u0022Bone occlusion\u0022,\n\t\t\t\tworkspace =\u003E workspace.BoneOcclusionEnabled =\n\t\t\t\t\t!workspace.BoneOcclusionEnabled ) );\n\t\tBindCheckedState(\n\t\t\tboneOcclusion,\n\t\t\t() =\u003E _controller.Document.Workspace.BoneOcclusionEnabled );\n\t\tvar ikBones = view.AddOption( \u0022Show IK Bones\u0022, \u0022polyline\u0022, () =\u003E\n\t\t\t_controller.UpdateWorkspacePreference(\n\t\t\t\t\u0022Show IK bones\u0022,\n\t\t\t\tworkspace =\u003E workspace.ShowIkBones = !workspace.ShowIkBones ) );\n\t\tBindCheckedState( ikBones, () =\u003E _controller.Document.Workspace.ShowIkBones );\n\t\tvar onionSkins = view.AddOption( \u0022Toggle Onion Skins\u0022, \u0022filter_none\u0022, () =\u003E\n\t\t\t_controller.Mutate( \u0022Onion skins\u0022, d =\u003E d.Workspace.ShowOnionSkins = !d.Workspace.ShowOnionSkins ) );\n\t\tBindCheckedState(\n\t\t\tonionSkins,\n\t\t\t() =\u003E _controller.Document.Workspace.ShowOnionSkins );\n\t\tvar cameraPreview = view.AddOption( \u0022Viewmodel Camera Preview\u0022, \u0022videocam\u0022, () =\u003E\n\t\t\t_controller.Mutate(\n\t\t\t\t\u0022Preview camera\u0022,\n\t\t\t\td =\u003E d.Workspace.FirstPersonPreview = !d.Workspace.FirstPersonPreview ) );\n\t\tBindCheckedState(\n\t\t\tcameraPreview,\n\t\t\t() =\u003E _controller.Document.Workspace.FirstPersonPreview );\n\t\tview.AddSeparator();\n\t\tview.AddOption( \u0022Reset Workspace\u0022, \u0022restart_alt\u0022, ResetWorkspace );\n\n\t\tvar tools = MenuBar.AddMenu( \u0022Tools\u0022 );\n\t\ttools.AddOption( \u0022Validate\u0022, \u0022rule\u0022, Validate );\n\t\ttools.AddOption( \u0022Rebuild Preview Rig\u0022, \u0022refresh\u0022, RebuildPreviewHost );\n\t\ttools.AddOption( \u0022Reimport Source\u0022, \u0022published_with_changes\u0022, ReimportSource );\n\t\ttools.AddOption( \u0022Refresh Materials\u0022, \u0022texture\u0022, RefreshMaterials );\n\t\ttools.AddOption( \u0022Open Generated Folder\u0022, \u0022folder\u0022, OpenGeneratedFolder );\n\t}\n\n\tprivate static void BindCheckedState( Option option, Func\u003Cbool\u003E 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( \u0022background-color: rgb(13,15,17); border: none;\u0022 );\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 \u002B= ( message ) =\u003E _statusPanel?.SetMessage( message );\n\t\t_viewport.LegacyIdleRepaired \u002B= () =\u003E _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( \u0022Save\u0022, \u0022save\u0022, () =\u003E Save() );\n\t\t_generateButton = _toolbar.AddLeft( \u0022Generate\u0022, \u0022build\u0022, GenerateAssets, true );\n\t\tif ( _controller.Document.ActiveStage == WeaponAnimatorStage.Animate )\n\t\t{\n\t\t\t_playButton = _toolbar.AddLeft( \u0022Play\u0022, \u0022play_arrow\u0022, TogglePlayback );\n\t\t}\n\t\t_toolbar.AddLeft( \u0022Undo\u0022, \u0022undo\u0022, _controller.Undo, overflowAtNarrowWidth: true );\n\t\t_toolbar.AddLeft( \u0022Redo\u0022, \u0022redo\u0022, _controller.Redo, overflowAtNarrowWidth: true );\n\n\t\tvar calibrate = _toolbar.AddCenter(\n\t\t\t\u00221  Calibrate\u0022,\n\t\t\t\u0022straighten\u0022,\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\u00222  Animate\u0022,\n\t\t\t\u0022animation\u0022,\n\t\t\t() =\u003E 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( \u0022Validate\u0022, \u0022rule\u0022, 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 \u002B= ImportSource;\n\t\trigPanel.RigReviewConfirmed \u002B= RebuildPreviewHost;\n\n\t\tvar inspector = new CalibrationInspectorPanel( _controller );\n\t\tinspector.PickRequested \u002B= _viewport.SetPickMode;\n\t\tinspector.AutoAlignRequested \u002B= AutoAlign;\n\t\tinspector.ConfirmRequested \u002B= ConfirmCalibration;\n\t\tinspector.RebuildPreviewRequested \u002B= RebuildPreviewHost;\n\t\tinspector.SetModelDimensions( _viewport.ModelDimensions );\n\t\t_viewport.ModelDimensionsChanged \u002B= 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( \u0022RIG AUDIT\u0022, \u0022account_tree\u0022, rigPanel );\n\t\tvar center = new PanelChrome( \u00223D CALIBRATION\u0022, \u0022view_in_ar\u0022, _viewport );\n\t\tvar right = new PanelChrome( \u0022CALIBRATION\u0022, \u0022tune\u0022, inspector );\n\t\tvar bottom = new PanelChrome( \u0022VALIDATION \u002B IMPORT\u0022, \u0022fact_check\u0022, _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 \u002B= ( message, severity ) =\u003E _statusPanel?.SetMessage( message, severity );\n\t\tinspector.StatusChanged \u002B= ( message, severity ) =\u003E _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( \u0022RIG BROWSER\u0022, \u0022account_tree\u0022, rigBrowser );\n\t\tvar center = new PanelChrome( \u00223D ANIMATION\u0022, \u0022view_in_ar\u0022, _viewport );\n\t\tvar right = new PanelChrome( \u0022SELECTED CONTROL\u0022, \u0022tune\u0022, inspector );\n\t\tvar clipRack = new PanelChrome( \u0022CLIP RACK\u0022, \u0022video_library\u0022, clips );\n\t\tvar bottom = new PanelChrome( \u0022DOPE SHEET \u00B7 CURVES \u00B7 TAGS\u0022, \u0022timeline\u0022, 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 = \u0022Import Rigged Weapon\u0022,\n\t\t\tDirectory = global::Editor.FileSystem.Content.GetFullPath( \u0022/\u0022 )\n\t\t};\n\t\tdialog.SetModeOpen();\n\t\tdialog.SetFindExistingFile();\n\t\tdialog.SetNameFilter( \u0022Rigged Models (*.fbx *.smd *.dmx *.vmdl)\u0022 );\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( \u0022Import source weapon\u0022, document =\u003E\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$\u0022{import?.Message} {host?.Message}\u0022,\n\t\t\timport?.Success == true \u0026\u0026 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( \u0022Reimport source weapon\u0022, document =\u003E\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 ?? \u0022Reimport failed.\u0022,\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\u0022Material refresh or generation is already in progress.\u0022,\n\t\t\t\tValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\t_refreshingMaterials = true;\n\t\tLog.Info( \u0022[Weapon Animator] manual material refresh requested.\u0022 );\n\t\ttry\n\t\t{\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\u0022Discovering and compiling source materials\u2026\u0022,\n\t\t\t\tValidationSeverity.Info );\n\t\t\tvar result = await RefreshMaterialsCoreAsync( \u0022Refresh source materials\u0022 );\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( $\u0022[Weapon Animator] material refresh threw: {ex}\u0022 );\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t$\u0022Material refresh failed: {ex.Message}\u0022,\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\u003CSourceImportResult\u003E RefreshMaterialsCoreAsync(\n\t\tstring historyDescription )\n\t{\n\t\tvar recovered = false;\n\t\tvar recoveryMessage = \u0022\u0022;\n\t\t_controller.Mutate(\n\t\t\t\u0022Recover missing source preview\u0022,\n\t\t\tdocument =\u003E 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 = \u0022The open document or source changed while materials were compiling; \u0022\n\t\t\t\t\t\u002B \u0022the candidate preview was not applied.\u0022\n\t\t\t};\n\t\t}\n\n\t\t_controller.Mutate(\n\t\t\thistoryDescription,\n\t\t\tdocument =\u003E 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\u0022Set the primary grip and both optional alignment markers before running Auto-align.\u0022,\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( \u0022The selected anchors cannot produce a finite alignment.\u0022, ValidationSeverity.Error );\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.Mutate( \u0022Auto-align weapon\u0022, d =\u003E\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 \u002B 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? \u0022Aligned, but the bore points appear reversed. Swap rear and front if the muzzle faces away from \u002BX.\u0022\n\t\t\t\t: \u0022Grip placed at the canonical hand origin; bore aligned to \u002BX and projected through the crosshair.\u0022,\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( \u0022Build calibrated preview host\u0022, document =\u003E\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 ?? \u0022\u0022 );\n\t\t\treturn;\n\t\t}\n\n\t\tvar previous = _controller.Document.Calibration.Snapshot;\n\t\t_controller.Mutate( \u0022Confirm calibration\u0022, document =\u003E\n\t\t{\n\t\t\tif ( _rebaseOnConfirm \u0026\u0026 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\u002B\u002B;\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\u003CSystem.Collections.Generic.List\u003CWeaponAnchor\u003E\u003E(\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 =\u003E\n\t\t\tx.Tracks.Count \u003E 0 \u0026\u0026 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() =\u003E\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() =\u003E\n\t\t\t{\n\t\t\t\tDialog.AskConfirm(\n\t\t\t\t\t() =\u003E\n\t\t\t\t\t{\n\t\t\t\t\t\t_controller.Mutate(\n\t\t\t\t\t\t\t\u0022Discard animation for recalibration\u0022,\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\u0022Discard all authored animation and binding data before recalibrating?\u0022,\n\t\t\t\t\t\u0022Discard Animation Data\u0022,\n\t\t\t\t\t\u0022Discard\u0022,\n\t\t\t\t\t\u0022Cancel\u0022 );\n\t\t\t},\n\t\t\t\u0022Rebase bindings, controls, and animation roots onto the new calibration when it is confirmed?\u0022,\n\t\t\t\u0022Return to Calibration\u0022,\n\t\t\t\u0022Rebase\u0022,\n\t\t\t\u0022Other Options\u0022 );\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\u0022Confirm a valid calibration before entering Animate.\u0022,\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( $\u0022Switch to {stage}\u0022, d =\u003E 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$\u0022Migration backup failed; the project was not saved: {ex.Message}\u0022,\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( \u0022The .wepanim asset could not be saved.\u0022, 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( $\u0022Saved {_asset.Path}.\u0022 );\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 = \u0022Save Weapon Animation Project As\u0022,\n\t\t\tDirectory = global::Editor.FileSystem.Content.GetFullPath( \u0022/\u0022 ),\n\t\t\tDefaultSuffix = \u0022wepanim\u0022\n\t\t};\n\t\tdialog.SetModeSave();\n\t\tdialog.SetFindFile();\n\t\tdialog.SetNameFilter( \u0022Weapon Animation Project (*.wepanim)\u0022 );\n\t\tif ( !dialog.Execute() )\n\t\t\treturn;\n\n\t\tvar path = Path.ChangeExtension( dialog.SelectedFile, \u0022.wepanim\u0022 );\n\t\tvar asset = AssetSystem.CreateResource( \u0022wepanim\u0022, path );\n\t\tif ( asset is null )\n\t\t{\n\t\t\t_statusPanel?.SetMessage( \u0022Could not create the new .wepanim asset.\u0022, 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( \u0022Save As failed.\u0022, 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\u0022Cancelling asset generation safely\u2026\u0022,\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( \u0022[Weapon Animator] asset generation requested.\u0022 );\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\u0022Discover source materials\u0022 );\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( \u0022Generating and compiling assets\u2026\u0022, ValidationSeverity.Info );\n\t\t\tvar result = await _generator.GenerateAsync(\n\t\t\t\t_controller.Document,\n\t\t\t\tprogress =\u003E\n\t\t\t\t{\n\t\t\t\t\tvar count = progress.Total \u003E 0\n\t\t\t\t\t\t? $\u0022 {progress.Completed}/{progress.Total}\u0022\n\t\t\t\t\t\t: \u0022\u0022;\n\t\t\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t\t\t$\u0022{progress.Stage}{count} \u2014 {progress.Detail}\u0022,\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$\u0022Generated and reloaded {result.GeneratedFiles.Count} files in {result.OutputFolder}.\u0022,\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\u0022Asset generation cancelled; previous owned outputs were restored.\u0022,\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\u0022  \u00B7  \u0022,\n\t\t\t\t\tresult.Diagnostics.Where( x =\u003E x.Severity == ValidationSeverity.Error )\n\t\t\t\t\t\t.Select( x =\u003E 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 ) ? \u0022Generation failed validation.\u0022 : 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\u0022Asset generation cancelled before outputs were changed.\u0022,\n\t\t\t\tValidationSeverity.Warning );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Error( $\u0022[Weapon Animator] generation threw: {ex}\u0022 );\n\t\t\t_statusPanel?.SetMessage( $\u0022Generation failed: {ex.Message}\u0022, 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 ? \u0022Cancel\u0022 : \u0022Generate\u0022;\n\t\t_generateButton.Icon = _generating ? \u0022stop\u0022 : \u0022build\u0022;\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( \u0022Rebuild preview host\u0022, document =\u003E\n\t\t\tresult = PreviewHostBuilder.Build( document ) );\n\t\t_statusPanel?.SetMessage(\n\t\t\tresult?.Message ?? \u0022Preview host rebuild failed.\u0022,\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( \u0022Generate assets before opening the output folder.\u0022, ValidationSeverity.Warning );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\t_statusPanel?.SetMessage(\n\t\t\t\t$\u0022Could not resolve the output folder: {ex.Message}\u0022,\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( \u0022Reset workspace\u0022, document =\u003E\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 = \u0022\u0022;\n\t\t\tstate.CalibrationVerticalSplitterState = \u0022\u0022;\n\t\t\tstate.AnimationSplitterState = \u0022\u0022;\n\t\t\tstate.AnimationVerticalSplitterState = \u0022\u0022;\n\t\t\tstate.AnimationTimelineSplitterState = \u0022\u0022;\n\t\t\tstate.AnimationRightSplitterState = \u0022\u0022;\n\t\t\tstate.AnimationMainSplitterState = \u0022\u0022;\n\t\t\tstate.AnimationOuterSplitterState = \u0022\u0022;\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\u002B\u002B;\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 \u0026\u0026 _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 \u0026\u0026 _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/// \u003Csummary\u003E\n\t/// The .wepanim filename is the project\u0027s 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/// \u003Cc\u003EWeaponAnimationAsset.Document\u003C/c\u003E is initialised with \u003Cc\u003ECreateDefault()\u003C/c\u003E \u2014 it is never\n\t/// null, so an asset created outside the New Project flow always arrives carrying the\n\t/// \u0022New Weapon\u0022 default and would otherwise generate into \u003Cc\u003Eweapons/new_weapon\u003C/c\u003E.\n\t/// \u003C/summary\u003E\n\tinternal static bool AdoptAssetFileName( WeaponAnimationDocument document, Asset? asset ) =\u003E\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( \u0027\\\\\u0027, \u0027/\u0027 ) );\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 ?? \u0022\u0022,\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( \u0027\\\\\u0027, \u0027/\u0027 ) );\n\t\tif ( string.IsNullOrWhiteSpace( fileName ) )\n\t\t\tfileName = \u0022New Weapon\u0022;\n\t\treturn $\u0022S\u0026box Weapon Animator \u2014 {fileName}{(dirty ? \u0022 *\u0022 : \u0022\u0022)}\u0022;\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 \u003E 0 ? $\u0022{report.WarningCount} warnings\u0022 : \u0022Valid\u0022\n\t\t\t: $\u0022{report.ErrorCount} errors\u0022;\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 \u003E 0 ? \u0022warning\u0022 : \u0022check_circle\u0022\n\t\t\t: \u0022error\u0022;\n\t\t_validationButton.Tint = report.IsValid\n\t\t\t? report.WarningCount \u003E 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 ? \u0022Pause\u0022 : \u0022Play\u0022;\n\t\t\t_playButton.Icon = _controller.IsPlaying ? \u0022pause\u0022 : \u0022play_arrow\u0022;\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() =\u003E\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? $\u0022Recovered the newer autosave snapshot. {migration.Summary}\u0022\n\t\t\t\t\t: \u0022Recovered the newer autosave snapshot.\u0022;\n\t\t\t\t_statusPanel?.SetMessage( message, ValidationSeverity.Warning );\n\t\t\t},\n\t\t\t() =\u003E RecoveryService.Clear( _controller.Document.DocumentId ),\n\t\t\t\u0022A newer recovery snapshot exists for this project. Restore it?\u0022,\n\t\t\t\u0022Recover Weapon Animation Project\u0022,\n\t\t\t\u0022Restore\u0022,\n\t\t\t\u0022Discard Recovery\u0022 );\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() =\u003E\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$\u0022{result.Message} {host?.Message}\u0022,\n\t\t\t\t\tresult.Success \u0026\u0026 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() =\u003E { },\n\t\t\t\u0022The saved document is empty, but a previous weapon import remains in its private cache. Recover that import?\u0022,\n\t\t\t\u0022Recover Cached Weapon Import\u0022,\n\t\t\t\u0022Recover Import\u0022,\n\t\t\t\u0022Ignore Cache\u0022 );\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 \u0022\u0022;\n\n\t\tvar wrapper = Directory.EnumerateFiles( cache, \u0022source_*.vmdl\u0022 )\n\t\t\t.OrderByDescending( File.GetLastWriteTimeUtc )\n\t\t\t.FirstOrDefault();\n\t\tif ( string.IsNullOrWhiteSpace( wrapper ) )\n\t\t\treturn \u0022\u0022;\n\n\t\tvar match = Regex.Match(\n\t\t\tFile.ReadAllText( wrapper ),\n\t\t\t\u0022filename\\\\s*=\\\\s*\\\u0022(?\u003Cpath\u003E[^\\\u0022]\u002B\\\\.(?:fbx|smd|dmx|vmdl))\\\u0022\u0022,\n\t\t\tRegexOptions.IgnoreCase );\n\t\tif ( !match.Success )\n\t\t\treturn \u0022\u0022;\n\n\t\tvar relative = match.Groups[\u0022path\u0022].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 \u0022\u0022;\n\n\t\treturn Directory.EnumerateFiles( directory )\n\t\t\t.FirstOrDefault( path =\u003E\n\t\t\t\tPath.GetFileName( path ).Equals( filename, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t?? \u0022\u0022;\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( \u0022weapon_root\u0022, 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\u002B\u002B;\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 ) =\u003E\n\t\tWeaponAnimationMigration.MigrateAndRepair( document );\n}\n\npublic static class WeaponAnimatorLauncher\n{\n\t[Menu( \u0022Editor\u0022, \u0022Tools/Weapon Animator/Open Weapon Animator\u0022, \u0022animation\u0022, 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 = \u0022Create Weapon Animation Project\u0022,\n\t\t\tDirectory = global::Editor.FileSystem.Content.GetFullPath( \u0022/\u0022 ),\n\t\t\tDefaultSuffix = \u0022wepanim\u0022\n\t\t};\n\t\tdialog.SetModeSave();\n\t\tdialog.SetFindFile();\n\t\tdialog.SetNameFilter( \u0022Weapon Animation Project (*.wepanim)\u0022 );\n\t\tif ( !dialog.Execute() )\n\t\t\treturn;\n\n\t\tvar path = Path.ChangeExtension( dialog.SelectedFile, \u0022.wepanim\u0022 );\n\t\tvar asset = AssetSystem.CreateResource( \u0022wepanim\u0022, 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 = \u0022Open Weapon Animation Project\u0022,\n\t\t\tDirectory = global::Editor.FileSystem.Content.GetFullPath( \u0022/\u0022 )\n\t\t};\n\t\tdialog.SetModeOpen();\n\t\tdialog.SetFindExistingFile();\n\t\tdialog.SetNameFilter( \u0022Weapon Animation Project (*.wepanim)\u0022 );\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 = \u0022Weapon Animator\u0022;\n\t\tTitle = WindowTitle;\n\t\tSize = new Vector2( 520, 260 );\n\t\tSetWindowIcon( \u0022animation\u0022 );\n\n\t\tvar root = new Widget( this );\n\t\troot.SetStyles( \u0022background-color: rgb(13,15,17);\u0022 );\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( \u0022WEAPON ANIMATOR\u0022, root );\n\t\ttitle.SetStyles(\n\t\t\t\u0022background-color: transparent; border: none; padding: 0px;\u0022 \u002B\n\t\t\t$\u0022font-size: 18px; font-weight: 600; letter-spacing: 1.2px; color: {WeaponAnimatorTheme.Text.Hex};\u0022 );\n\t\troot.Layout.Add( title );\n\t\tvar description = WeaponAnimatorTheme.Label(\n\t\t\t\u0022Open a document-driven import, calibration, binding, and animation workspace. No active scene or selected GameObject is required.\u0022,\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\u0022New project\u0022,\n\t\t\t\u0022note_add\u0022,\n\t\t\t() =\u003E\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\u0022Open existing\u0022,\n\t\t\t\u0022folder_open\u0022,\n\t\t\t() =\u003E\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 = \u0022Weapon Animator Preferences\u0022;\n\t\tTitle = WindowTitle;\n\t\tSize = new Vector2( 420, 520 );\n\t\tvar root = new Widget( this );\n\t\troot.SetStyles( \u0022background-color: rgb(13,15,17);\u0022 );\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\u0022Auto-key transformed controls\u0022,\n\t\t\tcontroller.Document.Workspace.AutoKey,\n\t\t\tvalue =\u003E controller.Mutate( \u0022Auto-key preference\u0022, d =\u003E d.Workspace.AutoKey = value ) ) );\n\t\troot.Layout.Add( Toggle(\n\t\t\t\u0022Use local gizmo space\u0022,\n\t\t\tcontroller.Document.Workspace.LocalGizmos,\n\t\t\tvalue =\u003E controller.Mutate( \u0022Gizmo preference\u0022, d =\u003E d.Workspace.LocalGizmos = value ) ) );\n\t\troot.Layout.Add( Toggle(\n\t\t\t\u0022Snap position\u0022,\n\t\t\tcontroller.Document.Workspace.SnapPosition,\n\t\t\tvalue =\u003E controller.Mutate( \u0022Position snapping\u0022, d =\u003E d.Workspace.SnapPosition = value ) ) );\n\t\troot.Layout.Add( Toggle(\n\t\t\t\u0022Snap rotation\u0022,\n\t\t\tcontroller.Document.Workspace.SnapRotation,\n\t\t\tvalue =\u003E controller.Mutate( \u0022Rotation snapping\u0022, d =\u003E d.Workspace.SnapRotation = value ) ) );\n\t\troot.Layout.Add( Number(\n\t\t\t\u0022Rotation snap angle\u0022,\n\t\t\tcontroller.Document.Workspace.RotationSnapDegrees,\n\t\t\t0.25f,\n\t\t\t180,\n\t\t\tvalue =\u003E controller.UpdateWorkspacePreference(\n\t\t\t\t\u0022Rotation snap angle\u0022,\n\t\t\t\tworkspace =\u003E workspace.RotationSnapDegrees = value ) ) );\n\t\troot.Layout.Add( WeaponAnimatorTheme.SectionLabel(\n\t\t\t\u0022VIEWPORT GRID\u0022,\n\t\t\troot,\n\t\t\ttopMargin: true ) );\n\t\troot.Layout.Add( Number(\n\t\t\t\u0022Grid opacity\u0022,\n\t\t\tcontroller.Document.Workspace.GridOpacity,\n\t\t\t0,\n\t\t\t0.5f,\n\t\t\tvalue =\u003E controller.UpdateWorkspacePreference(\n\t\t\t\t\u0022Grid opacity\u0022,\n\t\t\t\tworkspace =\u003E workspace.GridOpacity = value ) ) );\n\t\troot.Layout.Add( Number(\n\t\t\t\u0022Grid line weight\u0022,\n\t\t\tcontroller.Document.Workspace.GridLineThickness,\n\t\t\t0.1f,\n\t\t\t2,\n\t\t\tvalue =\u003E controller.UpdateWorkspacePreference(\n\t\t\t\t\u0022Grid line weight\u0022,\n\t\t\t\tworkspace =\u003E workspace.GridLineThickness = value ) ) );\n\t\troot.Layout.Add( WeaponAnimatorTheme.SectionLabel(\n\t\t\t\u0022VIEWPORT LIGHTING\u0022,\n\t\t\troot,\n\t\t\ttopMargin: true ) );\n\t\troot.Layout.Add( Toggle(\n\t\t\t\u0022Cyan edge light\u0022,\n\t\t\tcontroller.Document.Workspace.RimLightEnabled,\n\t\t\tvalue =\u003E controller.UpdateWorkspacePreference(\n\t\t\t\t\u0022Cyan edge light\u0022,\n\t\t\t\tworkspace =\u003E workspace.RimLightEnabled = value ) ) );\n\t\troot.Layout.Add( Number(\n\t\t\t\u0022Cyan edge brightness\u0022,\n\t\t\tcontroller.Document.Workspace.RimLightIntensity,\n\t\t\t0,\n\t\t\t12,\n\t\t\tvalue =\u003E controller.UpdateWorkspacePreference(\n\t\t\t\t\u0022Cyan edge brightness\u0022,\n\t\t\t\tworkspace =\u003E workspace.RimLightIntensity = value ) ) );\n\t\tvar lightingNote = WeaponAnimatorTheme.Label(\n\t\t\t\u0022The edge light is disabled automatically in Full Bright.\u0022,\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( \u0022Close\u0022, \u0022close\u0022, Close, root, true ) );\n\t\tCanvas = root;\n\n\t\tButton Toggle( string text, bool value, Action\u003Cbool\u003E 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 = () =\u003E 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\u003Cfloat\u003E 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( \u00220.##\u0022, 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( \u00220.##\u0022, 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 \u002B= textValue =\u003E\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\u0026\u0026 WeaponAnimationMath.IsFinite( parsed ) )\n\t\t\t\t\tApply( parsed, false );\n\t\t\t};\n\t\t\tedit.EditingFinished \u002B= () =\u003E Apply( slider.Value, true );\n\t\t\tslider.OnValueEdited = () =\u003E 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\u003CGuid, WeaponAnimatorButton\u003E _clipButtons = [];\n\tprivate readonly Dictionary\u003CGuid, int\u003E _propertyScrollByClip = [];\n\tprivate string _clipListSignature = \u0022\u0022;\n\tprivate Guid _lastSelectedClipId;\n\n\tpublic event Action\u003Cstring, ValidationSeverity\u003E? 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( \u0022CLIP RACK\u0022, 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\u0022Start\u0022,\n\t\t\t\u0022add_circle\u0022,\n\t\t\tStartSelectedFromDefault,\n\t\t\tactions,\n\t\t\ttrue ), 1 );\n\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\u0022Duplicate\u0022,\n\t\t\t\u0022content_copy\u0022,\n\t\t\tShowDuplicateMenu,\n\t\t\tactions ), 1 );\n\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\u0022Import\u0022,\n\t\t\t\u0022input\u0022,\n\t\t\tShowImportMenu,\n\t\t\tactions ), 1 );\n\t\tLayout.Add( actions );\n\n\t\t_actionHint = WeaponAnimatorTheme.Label( \u0022\u0022, 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\u0022Add custom clip\u0022,\n\t\t\t\u0022playlist_add\u0022,\n\t\t\tAddCustomClip,\n\t\t\tthis );\n\t\tLayout.Add( addCustom );\n\n\t\t_controller.DocumentChanged \u002B= 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( \u0022CORE\u0022, [\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( \u0022PRESENTATION\u0022, [\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( \u0022INTERACTION\u0022, [\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( \u0022INCREMENTAL\u0022, [\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 =\u003E x.Role == WeaponClipRole.Custom )\n\t\t\t\t.ToArray();\n\t\t\tif ( custom.Length \u003E 0 )\n\t\t\t{\n\t\t\t\t_clipCanvas.Layout.Add( Header( \u0022CUSTOM\u0022, _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? \u0022Select a clip.\u0022\n\t\t\t: selected.Readiness == ClipReadiness.NotStarted\n\t\t\t\t? \u0022Not started \u00B7 choose Start, Duplicate, or Import.\u0022\n\t\t\t\t: $\u0022{selected.Readiness} \u00B7 {selected.Duration:0.###} s at {selected.SampleRate:0.#} fps\u0022;\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\u003CWeaponClipRole\u003E 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( \u0022CLIP PROPERTIES\u0022, _propertiesCanvas ) );\n\t\tif ( clip.Role == WeaponClipRole.Custom )\n\t\t\tAddCustomClipProperties( clip );\n\t\tvar sequence = WeaponAnimatorTheme.Label(\n\t\t\t$\u0022Sequence: {WeaponAnimationNames.SequenceName( clip )}\u0022,\n\t\t\t_propertiesCanvas,\n\t\t\ttrue );\n\t\tsequence.ToolTip = \u0022Generated sequence name\u0022;\n\t\t_propertiesCanvas.Layout.Add( sequence );\n\t\tAddClipNumber(\n\t\t\t\u0022Duration\u0022,\n\t\t\tclip.Duration,\n\t\t\tvalue =\u003E _controller.Mutate( \u0022Clip duration\u0022, _ =\u003E\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\u0022Sample rate\u0022,\n\t\t\tclip.SampleRate,\n\t\t\tvalue =\u003E _controller.Mutate(\n\t\t\t\t\u0022Clip sample rate\u0022,\n\t\t\t\t_ =\u003E clip.SampleRate = Math.Clamp( value, 1, 240 ) ) );\n\t\t_propertiesCanvas.Layout.Add( ClipChoice(\n\t\t\t$\u0022Readiness: {clip.Readiness}\u0022,\n\t\t\tEnum.GetNames\u003CClipReadiness\u003E(),\n\t\t\tvalue =\u003E _controller.Mutate(\n\t\t\t\t\u0022Clip readiness\u0022,\n\t\t\t\t_ =\u003E clip.Readiness = Enum.Parse\u003CClipReadiness\u003E( value ) ) ) );\n\t\t_propertiesCanvas.Layout.Add( ClipChoice(\n\t\t\t$\u0022Interpolation: {DominantInterpolation( clip )}\u0022,\n\t\t\tEnum.GetNames\u003CTrackInterpolation\u003E(),\n\t\t\tvalue =\u003E _controller.Mutate( \u0022Track interpolation\u0022, _ =\u003E\n\t\t\t{\n\t\t\t\tvar interpolation = Enum.Parse\u003CTrackInterpolation\u003E( 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( \u0022TAGS\u0022, _propertiesCanvas ) );\n\t\tvar tagRow = RigAuditPanel.Row( _propertiesCanvas );\n\t\tvar name = new LineEdit( tagRow )\n\t\t{\n\t\t\tPlaceholderText = \u0022Tag name\u0022,\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\u0022Point\u0022,\n\t\t\t\u0022add_location\u0022,\n\t\t\t() =\u003E AddClipTag( name.Text, AnimationTagKind.Point ),\n\t\t\ttagRow ) );\n\t\ttagRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\u0022Range\u0022,\n\t\t\t\u0022linear_scale\u0022,\n\t\t\t() =\u003E 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$\u0022{tag.Name}  {tag.StartTime:0.###}\u2013{tag.EndTime:0.###}\u0022,\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( \u0022Name\u0022, 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 \u002B= () =\u003E\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\u0022A custom clip name cannot be empty.\u0022,\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\u0022Delete custom clip\u0022,\n\t\t\t\u0022delete\u0022,\n\t\t\t() =\u003E 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() =\u003E _controller.DeleteCustomClip( clip.Id ),\n\t\t\t$\u0022Delete the custom clip \u0027{clip.Name}\u0027 and all of its keys, curves, tags, and visibility tracks?\u0022,\n\t\t\t\u0022Delete Custom Clip\u0022,\n\t\t\t\u0022Delete\u0022,\n\t\t\t\u0022Cancel\u0022 );\n\t}\n\n\tprivate void AddClipNumber( string label, float value, Action\u003Cfloat\u003E 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( \u00220.###\u0022, 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 \u002B= () =\u003E\n\t\t{\n\t\t\tif ( float.TryParse( edit.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed )\n\t\t\t\t\u0026\u0026 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\u003Cstring\u003E values,\n\t\tAction\u003Cstring\u003E changed )\n\t{\n\t\tvar button = new WeaponAnimatorButton( text, \u0022expand_more\u0022, _propertiesCanvas )\n\t\t{\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Clicked = () =\u003E\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, () =\u003E 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( $\u0022Add tag {name}\u0022, document =\u003E\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 \u002B 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 ) =\u003E\n\t\tclip.Tracks.GroupBy( x =\u003E x.Interpolation )\n\t\t\t.OrderByDescending( x =\u003E x.Count() )\n\t\t\t.Select( x =\u003E x.Key )\n\t\t\t.FirstOrDefault();\n\n\tprivate void AddClipButton( WeaponAnimationClip clip )\n\t{\n\t\tvar button = new WeaponAnimatorButton( \u0022\u0022, _clipCanvas )\n\t\t{\n\t\t\tClicked = () =\u003E _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( $\u0022Start {clip.Name}\u0022, document =\u003E\n\t\t{\n\t\t\tdocument.Workspace.ClearWorkingPoses( clip.Id );\n\t\t\tdocument.Workspace.TimelineViews.RemoveAll( x =\u003E x.ClipId == clip.Id );\n\t\t\tdocument.Workspace.CurveViews.RemoveAll( x =\u003E 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 =\u003E x.Id == document.Binding.DefaultGripPoseId )?\n\t\t\t\t\t.Bones.FirstOrDefault( x =\u003E 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 =\u003E\n\t\t\tx.Id != selected.Id \u0026\u0026 x.Readiness != ClipReadiness.NotStarted ) )\n\t\t{\n\t\t\tvar captured = source;\n\t\t\tmenu.AddOption( captured.Name, null, () =\u003E 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( $\u0022Duplicate {source.Name}\u0022, _ =\u003E\n\t\t{\n\t\t\t_controller.Document.Workspace.ClearWorkingPoses( destination.Id );\n\t\t\t_controller.Document.Workspace.TimelineViews.RemoveAll( x =\u003E\n\t\t\t\tx.ClipId == destination.Id );\n\t\t\t_controller.Document.Workspace.CurveViews.RemoveAll( x =\u003E\n\t\t\t\tx.ClipId == destination.Id );\n\t\t\tvar copy = Json.Deserialize\u003CWeaponAnimationClip\u003E( 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( \u0022The source model exposes no importable sequences.\u0022, 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, () =\u003E\n\t\t\t{\n\t\t\t\tSequenceImportResult? result = null;\n\t\t\t\t_controller.Mutate( $\u0022Import {captured}\u0022, document =\u003E\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 =\u003E\n\t\t\t\t\t\tx.ClipId == selected.Id );\n\t\t\t\t\tdocument.Workspace.CurveViews.RemoveAll( x =\u003E\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 ?? \u0022Sequence import failed.\u0022,\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( \u0022Add custom clip\u0022, document =\u003E\n\t\t{\n\t\t\tvar count = document.Clips.Count( x =\u003E x.Role == WeaponClipRole.Custom ) \u002B 1;\n\t\t\tvar clip = WeaponAnimationClip.Create( WeaponClipRole.Custom );\n\t\t\tclip.Name = $\u0022Custom {count}\u0022;\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\u0022background-color: transparent; border: none; padding: 5px 0 0 0;\u0022 \u002B\n\t\t\t$\u0022font-size: 9px; font-weight: 600; letter-spacing: 0.65px; color: {WeaponAnimatorTheme.Muted.Hex};\u0022 );\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() =\u003E string.Join(\n\t\t\u0022|\u0022,\n\t\t_controller.Document.Clips.Select( x =\u003E\n\t\t\t$\u0022{x.Id}:{x.Role}:{x.Name}:{x.Readiness}\u0022 ) );\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 =\u003E \u0022\u25CB\u0022,\n\t\t\tClipReadiness.Draft =\u003E \u0022\u25D0\u0022,\n\t\t\tClipReadiness.Ready =\u003E \u0022\u25CF\u0022,\n\t\t\t_ =\u003E \u0022!\u0022\n\t\t};\n\t\tbutton.Text = $\u0022{marker}  {clip.Name}\u0022;\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 =\u003E WeaponAnimatorTheme.Green * 0.24f,\n\t\t\t\tClipReadiness.Warning =\u003E WeaponAnimatorTheme.Coral * 0.28f,\n\t\t\t\t_ =\u003E WeaponAnimatorTheme.Surface\n\t\t\t};\n\t\tbutton.ToolTip = clip.Readiness.ToString();\n\t}\n\n\tinternal ScrollArea ClipScroll =\u003E _clipScroll;\n\tinternal ScrollArea? PropertiesScroll =\u003E _propertiesScroll;\n\tinternal WeaponAnimatorButton? GetClipButton( Guid clipId ) =\u003E\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\u003Cstring, bool\u003E _expandedSections = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t[\u0022binding\u0022] = true,\n\t\t[\u0022constraints\u0022] = true,\n\t\t[\u0022animgraph\u0022] = false\n\t};\n\tpublic event Action\u003Cstring, ValidationSeverity\u003E? 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 \u002B= Rebuild;\n\t\t_controller.SelectionChanged \u002B= 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( \u0022CONTROL INSPECTOR\u0022 ) );\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( \u0022BINDING \u002B GRIP POSES\u0022, \u0022binding\u0022 )\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\u0026\u0026 selectedControl is \u0022@primary_hand\u0022 or \u0022@support_hand\u0022 )\n\t\t\t{\n\t\t\t\tvar instruction = WeaponAnimatorTheme.Label(\n\t\t\t\t\t\u0022Keep this hand selected. Choose its attachment bone from the menu below; \u0022\n\t\t\t\t\t\u002B \u0022you do not need to select the weapon bone in the rig browser.\u0022,\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 =\u003E x.IsWeaponBone )\n\t\t\t\t\t.Select( x =\u003E 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\u0022Attachment bone\u0022,\n\t\t\t\t\t() =\u003E string.IsNullOrWhiteSpace( selectedTarget.AttachedBone )\n\t\t\t\t\t\t? \u0022weapon_root (recommended on bind)\u0022\n\t\t\t\t\t\t: selectedTarget.AttachedBone,\n\t\t\t\t\tweaponBones.Prepend( \u0022(world)\u0022 ),\n\t\t\t\t\tvalue =\u003E _controller.Mutate( \u0022Change hand attachment\u0022, document =\u003E\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 == \u0022(world)\u0022 ? \u0022\u0022 : 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 ? $\u0022Unbind {selectedTarget.Name}\u0022 : $\u0022Bind {selectedTarget.Name}\u0022,\n\t\t\t\t\tselectedTarget.IsBound ? \u0022link_off\u0022 : \u0022link\u0022,\n\t\t\t\t\t() =\u003E 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? \u0022Two handed\u0022\n\t\t\t\t: \u0022One handed\u0022,\n\t\t\t\u0022pan_tool\u0022,\n\t\t\tToggleGripConfiguration,\n\t\t\tbindingRow ), 1 );\n\t\tbindingRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\u0022Save grip pose\u0022,\n\t\t\t\u0022save\u0022,\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\u0022Apply saved grip pose\u0022,\n\t\t\t\u0022front_hand\u0022,\n\t\t\tShowGripPoseMenu,\n\t\t\tbindingCanvas ) );\n\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tif ( clip is not null \u0026\u0026 !_controlToolsOnly )\n\t\t{\n\t\t\t_canvas.Layout.Add( Header( \u0022CLIP PROPERTIES\u0022 ) );\n\t\t\t_canvas.Layout.Add( NumericField(\n\t\t\t\t\u0022Duration (seconds)\u0022,\n\t\t\t\tclip.Duration,\n\t\t\t\tvalue =\u003E _controller.Mutate( \u0022Clip duration\u0022, _ =\u003E\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\u0022Sample rate\u0022,\n\t\t\t\tclip.SampleRate,\n\t\t\t\tvalue =\u003E _controller.Mutate( \u0022Clip sample rate\u0022, _ =\u003E\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\u0022Readiness\u0022,\n\t\t\t\t() =\u003E clip.Readiness.ToString(),\n\t\t\t\tEnum.GetNames\u003CClipReadiness\u003E(),\n\t\t\t\tvalue =\u003E _controller.Mutate(\n\t\t\t\t\t\u0022Clip readiness\u0022,\n\t\t\t\t\t_ =\u003E clip.Readiness = Enum.Parse\u003CClipReadiness\u003E( value ) ) ) );\n\t\t\t_canvas.Layout.Add( ChoiceButton(\n\t\t\t\t\u0022Interpolation\u0022,\n\t\t\t\t() =\u003E DominantInterpolation( clip ).ToString(),\n\t\t\t\tEnum.GetNames\u003CTrackInterpolation\u003E(),\n\t\t\t\tvalue =\u003E _controller.Mutate( \u0022Track interpolation\u0022, _ =\u003E\n\t\t\t\t{\n\t\t\t\t\tvar interpolation = Enum.Parse\u003CTrackInterpolation\u003E( 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( \u0022CONSTRAINTS\u0022, \u0022constraints\u0022 )\n\t\t\t: _canvas;\n\t\tif ( !_controlToolsOnly )\n\t\t\tconstraintCanvas.Layout.Add( Header( \u0022KEYING \u002B CONSTRAINTS\u0022 ) );\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\u0022Auto-key\u0022,\n\t\t\t\t_controller.Document.Workspace.AutoKey,\n\t\t\t\tvalue =\u003E _controller.Mutate( \u0022Auto-key\u0022, d =\u003E d.Workspace.AutoKey = value ),\n\t\t\t\ttoggles ), 1 );\n\t\t\ttoggles.Layout.Add( ToggleButton(\n\t\t\t\t\u0022Local gizmo\u0022,\n\t\t\t\t_controller.Document.Workspace.LocalGizmos,\n\t\t\t\tvalue =\u003E _controller.Mutate( \u0022Gizmo space\u0022, d =\u003E 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\u0022Constraint target\u0022,\n\t\t\t\u0022target\u0022,\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? \u0022No constraint target selected\u0022\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\u0022Constrain selected control\u0022,\n\t\t\t\u0022link\u0022,\n\t\t\tAddConstraint,\n\t\t\tconstraintCanvas ) );\n\n\t\tif ( !_controlToolsOnly )\n\t\t{\n\t\t\t_canvas.Layout.Add( Header( \u0022TAGS\u0022 ) );\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 = \u0022Tag name\u0022,\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\u0022Point\u0022,\n\t\t\t\t\u0022add_location\u0022,\n\t\t\t\t() =\u003E AddTag( tagName.Text, AnimationTagKind.Point ),\n\t\t\t\ttagRow ) );\n\t\t\ttagRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\t\u0022Range\u0022,\n\t\t\t\t\u0022linear_scale\u0022,\n\t\t\t\t() =\u003E 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$\u0022{tag.Name}  {tag.StartTime:0.###}\u2013{tag.EndTime:0.###}\u0022,\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( \u0022ANIMGRAPH PREVIEW\u0022, \u0022animgraph\u0022 )\n\t\t\t: _canvas;\n\t\tif ( !_controlToolsOnly )\n\t\t\tgraphCanvas.Layout.Add( Header( \u0022ANIMGRAPH PREVIEW\u0022 ) );\n\t\tvar graphActions = new[]\n\t\t{\n\t\t\t(\u0022Fire\u0022, \u0022b_attack\u0022, WeaponClipRole.Fire),\n\t\t\t(\u0022Dry\u0022, \u0022b_attack_dry\u0022, WeaponClipRole.FireDry),\n\t\t\t(\u0022Reload\u0022, \u0022b_reload\u0022, WeaponClipRole.Reload),\n\t\t\t(\u0022Sprint\u0022, \u0022b_sprint\u0022, WeaponClipRole.Sprint),\n\t\t\t(\u0022Inspect\u0022, \u0022b_inspect\u0022, 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 \u003C graphActions.Length; index\u002B\u002B )\n\t\t{\n\t\t\tvar captured = graphActions[index];\n\t\t\tvar row = graphRows[index \u003C 3 ? 0 : 1];\n\t\t\trow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\tcaptured.Item1,\n\t\t\t\t\u0022play_arrow\u0022,\n\t\t\t\t() =\u003E 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\u0022move_bob\u0022,\n\t\t\t_controller.Document.Graph.PreviewFloats.GetValueOrDefault( \u0022move_bob\u0022 ),\n\t\t\tvalue =\u003E _controller.Mutate( \u0022Preview move_bob\u0022, d =\u003E\n\t\t\t\td.Graph.PreviewFloats[\u0022move_bob\u0022] = 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$\u0022{(expanded ? \u0022\u25BE\u0022 : \u0022\u25B8\u0022)}  {title}\u0022,\n\t\t\t_canvas )\n\t\t{\n\t\t\tClicked = () =\u003E\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\u0026\u0026 controlName == \u0022@primary_hand\u0022\n\t\t\t\u0026\u0026 _controller.Document.Calibration.GetAnchor( AnchorKind.Grip ) is null )\n\t\t{\n\t\t\tStatusChanged?.Invoke(\n\t\t\t\t\u0022Set the primary grip anchor in Calibrate before binding the primary hand.\u0022,\n\t\t\t\tValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.Mutate(\n\t\t\ttarget.IsBound ? $\u0022Unbind {target.Name}\u0022 : $\u0022Bind {target.Name}\u0022,\n\t\t\tdocument =\u003E\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 \u0026\u0026 controlName == \u0022@primary_hand\u0022 )\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 == \u0022@primary_hand\u0022\n\t\t\t\t\t? \u0022primary_hand\u0022\n\t\t\t\t\t: \u0022support_hand\u0022;\n\t\t\t\tif ( bindingTarget.IsBound\n\t\t\t\t\t\u0026\u0026 !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( \u0022Save default grip pose\u0022, d =\u003E\n\t\t{\n\t\t\tvar grip = new GripPose\n\t\t\t{\n\t\t\t\tName = $\u0022Grip {d.Binding.GripPoses.Count \u002B 1}\u0022,\n\t\t\t\tBones = skeleton.Bones\n\t\t\t\t\t.Where( x =\u003E !x.IsWeaponBone\n\t\t\t\t\t\t\u0026\u0026 (x.Name.Contains( \u0022finger_\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t\t\t|| x.Name.Contains( \u0022clavicle_\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t\t\t|| x.Name.Contains( \u0022hand_\u0022, StringComparison.OrdinalIgnoreCase )) )\n\t\t\t\t\t.Select( x =\u003E 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( \u0022default_grip\u0022 );\n\t\t} );\n\t}\n\n\tprivate void ToggleGripConfiguration()\n\t{\n\t\t_controller.Mutate( \u0022Grip configuration\u0022, d =\u003E\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( \u0022No reusable grip poses have been saved.\u0022, 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, () =\u003E 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( $\u0022Apply {pose.Name}\u0022, document =\u003E\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\u0022Select an arm control and a weapon bone before adding a constraint.\u0022,\n\t\t\t\tValidationSeverity.Warning );\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.Mutate( \u0022Add timed constraint\u0022, _ =\u003E 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 =\u003E 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, () =\u003E _controller.Mutate(\n\t\t\t\t\u0022Constraint target\u0022,\n\t\t\t\td =\u003E 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( $\u0022Add tag {name}\u0022, document =\u003E\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 \u002B 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 =\u003E 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$\u0022Simulating {name}=true with {(clip.Readiness == ClipReadiness.NotStarted ? \u0022Idle fallback\u0022 : clip.Name)}.\u0022,\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( \u0027@\u0027 ).Replace( \u0027_\u0027, \u0027 \u0027 );\n\t\tif ( !string.IsNullOrWhiteSpace( workspace.SelectedBone ) )\n\t\t\treturn workspace.SelectedBone;\n\t\treturn \u0022No control selected\u0022;\n\t}\n\n\tprivate RigTarget? ResolveControl( string name ) =\u003E name switch\n\t{\n\t\t\u0022@primary_hand\u0022 =\u003E _controller.Document.Binding.PrimaryHand,\n\t\t\u0022@support_hand\u0022 =\u003E _controller.Document.Binding.SupportHand,\n\t\t\u0022@primary_elbow\u0022 =\u003E _controller.Document.Binding.PrimaryElbowPole,\n\t\t\u0022@support_elbow\u0022 =\u003E _controller.Document.Binding.SupportElbowPole,\n\t\t_ =\u003E null\n\t};\n\n\tprivate Widget NumericField(\n\t\tstring name,\n\t\tfloat value,\n\t\tAction\u003Cfloat\u003E 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( \u00220.###\u0022, 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 \u002B= () =\u003E\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\u003Cstring\u003E current,\n\t\tIEnumerable\u003Cstring\u003E values,\n\t\tAction\u003Cstring\u003E changed,\n\t\tWidget? parent = null )\n\t{\n\t\tparent ??= _canvas;\n\t\tvar button = new WeaponAnimatorButton( $\u0022{label}: {current()}\u0022, \u0022expand_more\u0022, parent )\n\t\t{\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Clicked = () =\u003E\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, () =\u003E\n\t\t\t\t{\n\t\t\t\t\tchanged( captured );\n\t\t\t\t\tbutton.Text = $\u0022{label}: {current()}\u0022;\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\u003Cbool\u003E 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 = () =\u003E 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 ) =\u003E\n\t\tclip.Tracks.GroupBy( x =\u003E x.Interpolation )\n\t\t\t.OrderByDescending( x =\u003E x.Count() )\n\t\t\t.Select( x =\u003E 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( \u0022Add key\u0022, \u0022key\u0022, AddKey, left, true ) );\n\t\tleft.Layout.Add( CompactAction( \u0022Copy\u0022, \u0022content_copy\u0022, _controller.CopySelectedKeys, left ) );\n\t\tleft.Layout.Add( CompactAction( \u0022Paste\u0022, \u0022content_paste\u0022, _controller.PasteKeys, left ) );\n\t\tleft.Layout.Add( CompactAction( \u0022Delete\u0022, \u0022delete\u0022, _controller.DeleteSelectedKeys, left ) );\n\t\tvar reverse = CompactAction( \u0022Reverse\u0022, \u0022swap_horiz\u0022, _controller.ReverseKeys, left );\n\t\treverse.ToolTip =\n\t\t\t\u0022Reverse selected keys within their time range. With no selection, reverse the whole clip.\u0022;\n\t\tleft.Layout.Add( reverse );\n\t\t_curvesButton = CompactAction(\n\t\t\t\u0022Curves\u0022,\n\t\t\t\u0022show_chart\u0022,\n\t\t\t() =\u003E _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( \u0022first_page\u0022, \u0022Jump to first frame\u0022, _controller.JumpToFirstFrame, player ) );\n\t\tplayer.Layout.Add( PlayerButton( \u0022skip_previous\u0022, \u0022Previous frame\u0022, () =\u003E _controller.StepTimelineFrame( -1 ), player ) );\n\t\t_playButton = PlayerButton( \u0022play_arrow\u0022, \u0022Play\u0022, _controller.TogglePlayback, player );\n\t\tplayer.Layout.Add( _playButton );\n\t\tplayer.Layout.Add( PlayerButton( \u0022skip_next\u0022, \u0022Next frame\u0022, () =\u003E _controller.StepTimelineFrame( 1 ), player ) );\n\t\tplayer.Layout.Add( PlayerButton( \u0022last_page\u0022, \u0022Jump to last frame\u0022, _controller.JumpToLastFrame, player ) );\n\t\t_loopButton = PlayerButton(\n\t\t\t\u0022repeat\u0022,\n\t\t\t\u0022Loop playback\u0022,\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( \u0022\u0022, 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 \u002B= Refresh;\n\t\t_controller.TimelineChanged \u002B= Refresh;\n\t\t_controller.TimelineViewChanged \u002B= Refresh;\n\t\t_controller.PlaybackChanged \u002B= Refresh;\n\t\t_controller.ClipPlaybackSettingsChanged \u002B= 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=\u003E _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 = \u0022No clip\u0022;\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$\u0022{_controller.Document.Workspace.TimelineTime:0.000}s \u00B7 {frame:00} / {total:00}\u0022;\n\t\t}\n\t\t_playButton.Icon = _controller.IsPlaying ? \u0022pause\u0022 : \u0022play_arrow\u0022;\n\t\t_playButton.ToolTip = _controller.IsPlaying ? \u0022Pause\u0022 : \u0022Play\u0022;\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? \u0022Loop playback is enabled\u0022\n\t\t\t: \u0022Loop playback\u0022;\n\t\tvar curves = _controller.Document.Workspace.CurveEditorVisible;\n\t\t_curvesButton.IsChecked = curves;\n\t\t_curvesButton.Text = curves ? \u0022Keys\u0022 : \u0022Curves\u0022;\n\t\t_curvesButton.Icon = curves ? \u0022view_timeline\u0022 : \u0022show_chart\u0022;\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? \u0022Return to the keyframe view\u0022\n\t\t\t: \u0022Open the curve editor\u0022;\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( \u0022\u0022, 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( \u0022background-color: rgb(24,27,30); border: none;\u0022 );\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 ) =\u003E\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 =\u003E 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\u002B 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( \u0022background-color: transparent; border: none;\u0022 );\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 =\u003E x.Keys ) )\n\t\t\tkey.Time = Math.Clamp( key.Time, 0, clip.Duration );\n\t\tforeach ( var key in clip.VisibilityTracks.SelectMany( x =\u003E 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 \u0026\u0026 !fullBright \u0026\u0026 safeIntensity \u003E 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/// \u003Csummary\u003E\n/// \u003Cparamref name=\u0022Hollow\u0022/\u003E draws the bone as a wireframe orb instead of a filled dot. Solid means\n/// \u0022you pose this directly\u0022; 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/// \u003C/summary\u003E\ninternal readonly record struct SkeletonBoneStyle(\n\tbool Visible,\n\tColor Color,\n\tfloat AlphaScale,\n\tfloat RadiusScale,\n\tbool Hollow )\n{\n\t/// \u003Csummary\u003E\n\t/// The Facepunch arms ship four IK helper bones (\u0060hand_*_to_*_ikrule\u0060) kept through compilation\n\t/// by BoneMarkup even though they skin nothing, and the host builder adds \u0060ik_hand_R\u0060/\u0060ik_hand_L\u0060\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/// \u003C/summary\u003E\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( \u0022_twist\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t? SkeletonBoneKind.Twist\n\t\t\t: SkeletonBoneKind.Arm;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Matches \u0060ik\u0060 and \u0060ikrule\u0060 as whole underscore-delimited tokens rather than as substrings, so\n\t/// \u0060weapon_IK_hand_R\u0060 and \u0060hand_R_to_weapon_ikrule\u0060 are caught while ordinary names that merely\n\t/// contain the letters - \u0060spike\u0060, \u0060strike_plate\u0060 - are not.\n\t/// \u003C/summary\u003E\n\tprivate static bool HasIkToken( string name )\n\t{\n\t\tforeach ( var token in name.Split( \u0027_\u0027, StringSplitOptions.RemoveEmptyEntries ) )\n\t\t{\n\t\t\tif ( token.Equals( \u0022ik\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t\t|| token.Equals( \u0022ikrule\u0022, 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 \u003E 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/// \u003Csummary\u003E\n\t/// Occluded bones use smaller marks so they stay readable without competing with visible bones.\n\t/// \u003C/summary\u003E\n\tpublic const float OccludedDotScale = 0.55f;\n\tpublic const float OccludedLineThickness = 0.6f;\n\tpublic const int OcclusionGradientSegments = 10;\n\n\t/// \u003Csummary\u003E\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 \u0022behind something\u0022 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/// \u003C/summary\u003E\n\tpublic static Color Occlude( Color color )\n\t{\n\t\tvar luminance = (color.r * 0.299f) \u002B (color.g * 0.587f) \u002B (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\u0026\u0026 WeaponAnimationMath.IsFinite( hitDistance )\n\t\t\t\u0026\u0026 targetDistance - hitDistance \u003E 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 \u0026\u0026 safe \u003E 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 \u002B ((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\u0026\u0026 (targetIsWeaponBone\n\t\t\t\t|| (targetArmSide != 0 \u0026\u0026 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\u003Cfloat\u003E _getValue;\n\tprivate readonly Action\u003Cfloat\u003E _setValue;\n\n\tpublic RotationSnapStepWidget(\n\t\tFunc\u003Cfloat\u003E getValue,\n\t\tAction\u003Cfloat\u003E 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 = \u0022Rotation snap angle\u0022;\n\t\tSetStyles(\n\t\t\t\u0022background-color: rgb(20,23,26);\u0022 \u002B\n\t\t\t\u0022border: 1px solid rgba(255,255,255,0.09);\u0022 \u002B\n\t\t\t\u0022border-radius: 3px;\u0022 );\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\u0022background-color: transparent; border: none;\u0022 \u002B\n\t\t\t\u0022color: rgb(224,229,234); font-size: 11px;\u0022 \u002B\n\t\t\t\u0022text-align: right; padding: 0 1px 0 2px;\u0022 );\n\t\t_edit.TextEdited \u002B= ApplyText;\n\t\t_edit.EditingFinished \u002B= Refresh;\n\t\tLayout.Add( _edit, 1 );\n\n\t\tvar suffix = WeaponAnimatorTheme.Label( \u0022\u00B0\u0022, 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( \u0022keyboard_arrow_up\u0022, () =\u003E 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 = \u0022Increase rotation snap angle\u0022\n\t\t} );\n\t\tbuttons.Add( new IconButton( \u0022keyboard_arrow_down\u0022, () =\u003E 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 = \u0022Decrease rotation snap angle\u0022\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( \u00220.##\u0022, 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\u0026\u0026 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 = \u0022weaponanim_arms_occluder\u0022;\n\tprivate static readonly float[] RotationSnapSteps =\n\t\t[0.25f, 0.5f, 1, 5, 15, 30, 45, 90, 180];\n\tprivate Rect TransformReadoutRect =\u003E\n\t\tnew( 260, Width \u003C 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 = \u0022\u0022;\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\u003Cstring, int\u003E _boneDepths =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate readonly Dictionary\u003Cstring, Transform\u003E _occlusionPose =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate readonly HashSet\u003Cstring\u003E _occludedBones =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate Transform _occlusionCameraTransform;\n\tprivate bool _occlusionCacheValid;\n\tprivate bool _occlusionFollowupPending;\n\tprivate string _lastOcclusionDiagnostic = \u0022\u0022;\n\tprivate RealTimeSince _sinceOcclusionTrace = 99;\n\tprivate int _maxBoneDepth;\n\tprivate string _loadedSource = \u0022\u0022;\n\tprivate string _loadedHost = \u0022\u0022;\n\tprivate string _lastDiagnosticSelection = \u0022\u0022;\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 = \u0022\u0022;\n\tprivate Transform _calibrationGizmoStartWorld;\n\tprivate Transform _calibrationGizmoStartLocal;\n\tprivate Vector3 _calibrationGizmoMoveDelta;\n\tprivate Vector3 _calibrationGizmoScaleDelta;\n\tprivate string _animationGizmoTarget = \u0022\u0022;\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/// \u003Csummary\u003E\n\t/// Which custom anchor a \u003Csee cref=\u0022ViewportPickMode.CustomAnchor\u0022/\u003E pick will place.\n\t/// \u003C/summary\u003E\n\tpublic Guid PickAnchorId { get; set; }\n\tpublic bool IsPlaying =\u003E _controller.IsPlaying;\n\tpublic WeaponAnimatorTransformMode TransformMode { get; private set; }\n\tpublic Vector3 ModelDimensions =\u003E _sourceRenderer?.Model?.Bounds.Size ?? Vector3.Zero;\n\tpublic bool ConsumesFreeLookMovementShortcut =\u003E\n\t\t_controller.Document.Workspace.FreeLookCamera\n\t\t\u0026\u0026 !_controller.Document.Workspace.FirstPersonPreview\n\t\t\u0026\u0026 IsActiveWindow\n\t\t\u0026\u0026 IsUnderMouse\n\t\t\u0026\u0026 PickMode == ViewportPickMode.None;\n\tpublic event Action\u003Cstring\u003E? StatusChanged;\n\tpublic event Action\u003CVector3\u003E? 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, \u0022weapon_animator_camera\u0022 )\n\t\t\t\t.GetOrAddComponent\u003CCameraComponent\u003E( 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, \u0022ambient\u0022 )\n\t\t\t\t.GetOrAddComponent\u003CAmbientLight\u003E( 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, \u0022key_light\u0022 )\n\t\t\t\t.GetOrAddComponent\u003CDirectionalLight\u003E( 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, \u0022rim_light\u0022 )\n\t\t\t\t.GetOrAddComponent\u003CPointLight\u003E( 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( \u0022materials/dev/primary_white.vmat\u0022 );\n\t\tApplyViewportRenderStyle();\n\n\t\t_moveModeButton = AddTransformModeButton(\n\t\t\t\u0022open_with\u0022,\n\t\t\t\u0022Move (W)\u0022,\n\t\t\tWeaponAnimatorTransformMode.Move,\n\t\t\tnew Vector2( 10, 10 ) );\n\t\t_rotateModeButton = AddTransformModeButton(\n\t\t\t\u0022360\u0022,\n\t\t\t\u0022Rotate (E)\u0022,\n\t\t\tWeaponAnimatorTransformMode.Rotate,\n\t\t\tnew Vector2( 41, 10 ) );\n\t\t_scaleModeButton = AddTransformModeButton(\n\t\t\t\u0022zoom_out_map\u0022,\n\t\t\t\u0022Scale (R)\u0022,\n\t\t\tWeaponAnimatorTransformMode.Scale,\n\t\t\tnew Vector2( 72, 10 ) );\n\t\t_spaceButton = new WeaponAnimatorButton( \u0022\u0022, \u0022public\u0022, 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\u0022\u0022,\n\t\t\t\u0022rotate_90_degrees_cw\u0022,\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 = \u0022Toggle rotation snapping\u0022\n\t\t};\n\t\t_rotationSnapButton.Raise();\n\t\t_rotationSnapStep = new RotationSnapStepWidget(\n\t\t\t() =\u003E _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\u0022360\u0022,\n\t\t\t\u0022Orbit camera\u0022,\n\t\t\t() =\u003E SetCameraMode( false ) );\n\t\t_freeLookCameraButton = AddViewportActionButton(\n\t\t\t\u0022videocam\u0022,\n\t\t\t\u0022Free look camera \u2014 RMB look, WASD move, wheel changes speed, Shift moves faster\u0022,\n\t\t\t() =\u003E SetCameraMode( true ) );\n\t\t_lightingButton = AddViewportActionButton(\n\t\t\t\u0022light_mode\u0022,\n\t\t\t\u0022Toggle lit / full bright\u0022,\n\t\t\tToggleViewportLighting );\n\t\tPositionViewportActions();\n\n\t\t_controller.DocumentChanged \u002B= OnDocumentChanged;\n\t\t_controller.PoseChanged \u002B= Update;\n\t\t_controller.SelectionChanged \u002B= OnSelectionChanged;\n\t\t_controller.TimelineChanged \u002B= 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( $\u0022{TransformModeName( mode )} gizmo selected.\u0022 );\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( \u0022\u0022, icon, this )\n\t\t{\n\t\t\tIsToggle = true,\n\t\t\tClicked = () =\u003E 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( \u0022\u0022, 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 ? \u0022Free look camera\u0022 : \u0022Orbit camera\u0022,\n\t\t\tstate =\u003E\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\u002B 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\u0022Viewport lighting\u0022,\n\t\t\tstate =\u003E 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? \u0022Full bright \u2014 click for Lit\u0022\n\t\t\t: \u0022Lit \u2014 click for Full bright\u0022;\n\t}\n\n\tprivate void ToggleTransformSpace()\n\t{\n\t\t_controller.Mutate(\n\t\t\t\u0022Transform coordinate space\u0022,\n\t\t\tdocument =\u003E 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\u0022Rotation snapping\u0022,\n\t\t\tstate =\u003E 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\u0022Rotation snap angle\u0022,\n\t\t\tstate =\u003E 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? \u0022Local space \u2014 click for World\u0022\n\t\t\t: \u0022World space \u2014 click for Local\u0022;\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$\u0022{TransformModeName( TransformMode ).ToUpperInvariant()} \u00B7 {(local ? \u0022LOCAL\u0022 : \u0022WORLD\u0022)}\u0022;\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 ) =\u003E\n\t\tmode switch\n\t\t{\n\t\t\tWeaponAnimatorTransformMode.Rotate =\u003E \u0022Rotate\u0022,\n\t\t\tWeaponAnimatorTransformMode.Scale =\u003E \u0022Scale\u0022,\n\t\t\t_ =\u003E \u0022Move\u0022\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? \u0022Pick mode cleared.\u0022\n\t\t\t: $\u0022Click the weapon surface or a bone to set {PickLabel( mode )}.\u0022 );\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 = \u0022\u0022;\n\t\t\t_loadedHost = \u0022\u0022;\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 \u0026\u0026 !sourceModel.IsError )\n\t\t\t\t{\n\t\t\t\t\tvar sourceObject = new GameObject( true, \u0022source_weapon_preview\u0022 );\n\t\t\t\t\t_sourceRenderer = sourceObject.GetOrAddComponent\u003CSkinnedModelRenderer\u003E( 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$\u0022[Weapon Animator] source preview model is unavailable: \u0022\n\t\t\t\t\t\t\u002B $\u0022\u0027{document.Source.CompiledModelPath}\u0027. \u0022\n\t\t\t\t\t\t\u002B \u0022The viewport will wait for a path change or a manual rebuild.\u0022 );\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 \u0026\u0026 !armsModel.IsError )\n\t\t\t{\n\t\t\t\tvar armsObject = new GameObject( true, \u0022facepunch_arms_preview\u0022 );\n\t\t\t\tarmsObject.Tags.Add( ArmsOccluderTag );\n\t\t\t\t_armsRenderer = armsObject.GetOrAddComponent\u003CSkinnedModelRenderer\u003E( 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\u003CModelHitboxes\u003E( 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\u0026\u0026 !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 \u0026\u0026 !hostModel.IsError )\n\t\t\t\t{\n\t\t\t\t\t_hostRenderer = new GameObject( true, \u0022animation_host_preview\u0022 )\n\t\t\t\t\t\t.GetOrAddComponent\u003CSkinnedModelRenderer\u003E( 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$\u0022[Weapon Animator] animation host preview is unavailable: \u0022\n\t\t\t\t\t\t\u002B $\u0022\u0027{document.Source.PreviewHostPath}\u0027. \u0022\n\t\t\t\t\t\t\u002B \u0022The viewport will wait for a path change or a manual rebuild.\u0022 );\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif ( _controller.Document.Workspace.CameraDistance \u003C= 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 \u0026\u0026 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\u0026\u0026 global::Editor.Application.MouseButtons.HasFlag( MouseButtons.Right )\n\t\t\t\t\u0026\u0026 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 \u003C= 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( \u0022weapon_animator_grid\u0022 );\n\t\tfor ( var index = -halfLines; index \u003C= halfLines; index\u002B\u002B )\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 \u0026 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 \u002B 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( \u0022Viewport camera rotation\u0022 );\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\u0022Free look camera speed\u0022,\n\t\t\t\tstate =\u003E 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 \u003E 0 ? 0.9f : 1.1f),\n\t\t\t2,\n\t\t\t4096 );\n\t\t_controller.MarkWorkspacePreferenceChanged( \u0022Orbit camera distance\u0022 );\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\u0026\u0026 document.Source.PreviewHostPath != _loadedHost)\n\t\t\t|| (document.ActiveStage == WeaponAnimatorStage.Calibrate \u0026\u0026 _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\u0026\u0026 ShouldRetryMissingSourcePreview( requestedSource, _loadedSource ) )\n\t\t\tRebuildPreview();\n\t}\n\n\tinternal static bool ShouldRetryMissingSourcePreview(\n\t\tstring requestedSource,\n\t\tstring attemptedSource ) =\u003E\n\t\t!string.IsNullOrWhiteSpace( requestedSource )\n\t\t\u0026\u0026 !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 \u002B= rotation.Forward;\n\t\tif ( global::Editor.Application.IsKeyDown( KeyCode.S ) )\n\t\t\tmovement \u002B= rotation.Backward;\n\t\tif ( global::Editor.Application.IsKeyDown( KeyCode.A ) )\n\t\t\tmovement \u002B= rotation.Left;\n\t\tif ( global::Editor.Application.IsKeyDown( KeyCode.D ) )\n\t\t\tmovement \u002B= 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 \u002B= movement.Normal * speed * RealTime.Delta;\n\t\t_controller.MarkWorkspacePreferenceChanged( \u0022Free look camera position\u0022 );\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 \u003C 5.0f\n\t\t\t? 0.25f\n\t\t\t: currentSpeed \u003C 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 \u002B 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 \u003C RotationSnapSteps.Length; index\u002B\u002B )\n\t\t{\n\t\t\tvar distance = MathF.Abs( currentAngle - RotationSnapSteps[index] );\n\t\t\tif ( distance \u003E= 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 \u002B 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 \u0026\u0026 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\u0026\u0026 pose.Model.TryGetValue( \u0022weapon_root\u0022, 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\u0026\u0026 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\u0026\u0026 _hostSkeleton.ByName.TryGetValue( definition.Name, out var hostBone )\n\t\t\t\t\u0026\u0026 pose.Local.TryGetValue( definition.Name, out var currentLocal )\n\t\t\t\t\u0026\u0026 !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$\u0022[Weapon Animator] preview bodygroup \u0027{part.BodyGroupName}\u0027 failed: {ex.Message}\u0022 );\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\u0026\u0026 _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\u003CBoneCollection.Bone\u003E();\n\t\t\tqueue.Enqueue( root );\n\t\t\twhile ( queue.Count \u003E 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 ( \u002B\u002B_armPoseDiagnosticFrames \u003C 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\u002B\u002B;\n\t\t\tif ( WeaponPoseProjection.TransformNear( expected, actual, 0.001f ) )\n\t\t\t\tcontinue;\n\t\t\tmismatches\u002B\u002B;\n\t\t\tif ( mismatches \u003C= 4 )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\u0022[Weapon Animator] arm pose mismatch \u0027{bone.Name}\u0027: \u0022\n\t\t\t\t\t\u002B $\u0022expected={expected}, actual={actual}.\u0022 );\n\t\t\t}\n\t\t}\n\n\t\tLog.Info(\n\t\t\t$\u0022[Weapon Animator] arm pose bridge checked {compared} bones; \u0022\n\t\t\t\u002B $\u0022{mismatches} renderer override mismatches.\u0022 );\n\t}\n\n\tprivate void LogSourcePoseDiagnostics( EvaluatedPose pose )\n\t{\n\t\tif ( _sourcePoseDiagnosticsLogged || !_sourceRenderer.IsValid() )\n\t\t\treturn;\n\t\tif ( \u002B\u002B_sourcePoseDiagnosticFrames \u003C 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\u002B\u002B;\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 \u003C= 0.001f\n\t\t\t\t\u0026\u0026 rotationDelta \u003C= 0.001f\n\t\t\t\t\u0026\u0026 scaleDelta \u003C= 0.001f )\n\t\t\t\tcontinue;\n\n\t\t\tmismatches\u002B\u002B;\n\t\t\tLog.Warning(\n\t\t\t\t$\u0022[Weapon Animator] source pose mismatch \u0027{definition.Name}\u0027: \u0022\n\t\t\t\t\u002B $\u0022position={positionDelta:0.######}, \u0022\n\t\t\t\t\u002B $\u0022rotation={rotationDelta:0.######}, \u0022\n\t\t\t\t\u002B $\u0022scale={scaleDelta:0.######}; \u0022\n\t\t\t\t\u002B $\u0022expected={expected}, actual={actual}.\u0022 );\n\t\t}\n\n\t\tLog.Info(\n\t\t\t$\u0022[Weapon Animator] source pose bridge checked {compared} retained bones; \u0022\n\t\t\t\u002B $\u0022{mismatches} renderer override mismatches.\u0022 );\n\t\tif ( _hostSkeleton is not null\n\t\t\t\u0026\u0026 _hostSkeleton.ByName.TryGetValue( \u0022root\u0022, out var hostRoot )\n\t\t\t\u0026\u0026 _hostSkeleton.ByName.TryGetValue( \u0022weapon_root\u0022, out var weaponRoot )\n\t\t\t\u0026\u0026 pose.Model.TryGetValue( \u0022weapon_root\u0022, out var rootWorld )\n\t\t\t\u0026\u0026 pose.Local.TryGetValue( \u0022weapon_root\u0022, out var rootLocal ) )\n\t\t{\n\t\t\tLog.Info(\n\t\t\t\t$\u0022[Weapon Animator] root bridge: hostRootBind={hostRoot.BindModelTransform}, \u0022\n\t\t\t\t\u002B $\u0022weaponRootBindModel={weaponRoot.BindModelTransform}, \u0022\n\t\t\t\t\u002B $\u0022weaponRootBindLocal={_hostSkeleton.GetBindLocal( weaponRoot )}, \u0022\n\t\t\t\t\u002B $\u0022poseRootWorld={rootWorld}, poseRootLocal={rootLocal}, \u0022\n\t\t\t\t\u002B $\u0022sourceRenderer={_sourceRenderer!.WorldTransform}.\u0022 );\n\t\t}\n\t}\n\n\tprivate HashSet\u003Cstring\u003E 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 =\u003E\n\t\t\t\tx.RenderMode == VisibilityRenderMode.BoneBranch\n\t\t\t\t\u0026\u0026 !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 =\u003E x.BoneName )\n\t\t\t.Where( x =\u003E !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\u0022Repair generated Idle bind pose\u0022,\n\t\t\tdocument =\u003E\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\u0026\u0026 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\u0022Restored the generated Idle clip to the current calibrated bind pose. \u0022\n\t\t\t\u002B \u0022A versioned backup will be created on save.\u0022 );\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 = \u0022\u0022;\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( \u0022weapon_root\u0022, 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 =\u003E\n\t\t\tx.Target.Equals( \u0022weapon_root\u0022, StringComparison.OrdinalIgnoreCase ) );\n\t\tLog.Info(\n\t\t\t$\u0022[Weapon Animator] Preview diagnostic: selected=weapon_root, \u0022\n\t\t\t\u002B $\u0022sourceModel={_loadedSource}, hostModel={_loadedHost}, \u0022\n\t\t\t\u002B $\u0022sourceScale={_sourceRenderer?.WorldTransform.Scale}, \u0022\n\t\t\t\u002B $\u0022hostRendering={_hostRenderer?.SceneObject.RenderingEnabled}, \u0022\n\t\t\t\u002B $\u0022rootKeys={rootTrack?.Keys.Count ?? 0}, \u0022\n\t\t\t\u002B $\u0022workingOverride={_controller.Document.Workspace.GetWorkingPose(\n\t\t\t\tclip?.Id ?? Guid.Empty,\n\t\t\t\t\u0022weapon_root\u0022 ) is not null}.\u0022 );\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 \u0026\u0026 _controller.Document.Workspace.XRaySkeleton,\n\t\t\t1.0f );\n\n\t\tusing ( Gizmo.Scope( \u0022source_skeleton\u0022 ) )\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\u0026\u0026 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( $\u0022source_bone:{bone.Name}\u0022, 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 \u0026\u0026 _controller.Document.Workspace.XRaySkeleton,\n\t\t\talpha );\n\n\t\tvar occludedBones = style.DrawThroughMeshes\n\t\t\t\u0026\u0026 _controller.Document.Workspace.BoneOcclusionEnabled\n\t\t\t? ResolveOccludedBones( pose, useRenderedArms )\n\t\t\t: null;\n\t\tif ( occludedBones is { Count: \u003E 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( \u0022host_skeleton_behind\u0022 ) )\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( \u0022host_skeleton\u0022 ) )\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\u003Cstring\u003E ResolveOccludedBones(\n\t\tEvaluatedPose pose,\n\t\tbool useRenderedArms )\n\t{\n\t\tvar samples = new List\u003C(HostBone Bone, Transform Transform)\u003E();\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 \u0026\u0026 !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 \u0026\u0026 !_occlusionFollowupPending )\n\t\t\treturn _occludedBones;\n\t\tif ( !cacheMatches\n\t\t\t\u0026\u0026 _occlusionCacheValid\n\t\t\t\u0026\u0026 _sinceOcclusionTrace \u003C (1.0f / 30.0f) )\n\t\t\treturn _occludedBones;\n\n\t\tvar completingFollowup = cacheMatches \u0026\u0026 _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\u003C(HostBone Bone, Transform Transform)\u003E 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 = \u0022none\u0022;\n\t\tif ( !Scene.IsValid()\n\t\t\t|| targetPosition.Distance( _camera.WorldPosition ) \u003C= 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 = \u0022\u0022;\n\t\tvar unknownArmExample = \u0022\u0022;\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\u0026\u0026 _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 \u0026\u0026 hitSide != 0 )\n\t\t\t\t{\n\t\t\t\t\tsameArmHits\u002B\u002B;\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\u002B\u002B;\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\u002B\u002B;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\toppositeArmHits\u002B\u002B;\n\t\t\tif ( armTrace.Distance \u003E= 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? $\u0022arms hitbox (unknown bone) at {armTrace.Distance:0.###}\u0022\n\t\t\t\t: $\u0022arms hitbox ({hitBoneName}) at {armTrace.Distance:0.###}\u0022;\n\t\t}\n\n\t\tdescription \u002B=\n\t\t\t$\u0022; armHits=opposite:{oppositeArmHits},self:{sameArmHits}\u0022\n\t\t\t\u002B $\u0022({sameArmExample}),near:{nearArmHits},unknown:{unknownArmHits}\u0022\n\t\t\t\u002B $\u0022({unknownArmExample}); target={targetDistance:0.###},\u0022\n\t\t\t\u002B $\u0022clearance={depthClearance:0.###},weaponIgnored=True\u0022;\n\t\treturn nearestDistance \u003C 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 \u003E= 0 \u0026\u0026 _armsRenderer.IsValid() )\n\t\t\treturn _armsRenderer!.Model.GetBoneName( trace.Bone );\n\t\treturn \u0022\u0022;\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 = $\u0022{target.Name}|{target.ArmSide}|{occluded}\u0022;\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$\u0022[Weapon Animator] X-ray diagnostic: target={target.Name}, \u0022\n\t\t\t\u002B $\u0022targetSide={target.ArmSide}, firstHit={hitDescription}, \u0022\n\t\t\t\u002B $\u0022reduced={occluded}.\u0022 );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Bone depth drives the overlay gradient. \u003Cc\u003EHostSkeleton.Bones\u003C/c\u003E is topologically ordered,\n\t/// so one forward pass resolves every depth. \u003Cc\u003EBuildCached\u003C/c\u003E 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/// \u003C/summary\u003E\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\u0026\u0026 _boneDepths.TryGetValue( bone.ParentName, out var parentDepth )\n\t\t\t\t\t? parentDepth \u002B 1\n\t\t\t\t\t: 0;\n\t\t\t_boneDepths[bone.Name] = depth;\n\t\t\tif ( depth \u003E _maxBoneDepth\n\t\t\t\t\u0026\u0026 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\u003Cstring\u003E? onlyBones = null,\n\t\tIReadOnlySet\u003Cstring\u003E? excludedBones = null,\n\t\tIReadOnlySet\u003Cstring\u003E? 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 \u0026\u0026 !onlyBones.Contains( bone.Name ) )\n\t\t\t\tcontinue;\n\t\t\tif ( excludedBones is not null \u0026\u0026 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\u0026\u0026 _hostSkeleton.ByName.TryGetValue( bone.ParentName, out var parentBone )\n\t\t\t\t\u0026\u0026 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( $\u0022host_bone:{bone.Name}\u0022, 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 \u0026\u0026 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\u003Cstring\u003E occludedBones,\n\t\tSkeletonOverlayStyle overlay )\n\t{\n\t\tvar showIk = _controller.Document.Workspace.ShowIkBones;\n\t\tusing var scope = Gizmo.Scope( \u0022host_skeleton_occlusion_gradients\u0022 );\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 \u003C SkeletonOverlayStyle.OcclusionGradientSegments;\n\t\t\t\tsegment\u002B\u002B )\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 \u002B 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 \u002B 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 \u002B (delta * startFraction),\n\t\t\t\t\tparentTransform.Position \u002B (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 \u0026\u0026 !bone.IsWeaponBone \u0026\u0026 _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\u0026\u0026 _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( \u0022onion_skins\u0022 );\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 \u002B 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\u0022hand_R\u0022 );\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\u0022hand_L\u0022 );\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 \u0022far out of reach\u0022 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\u0026\u0026 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( \u0022grip_tether\u0022 );\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 = $\u0022anchor:{anchor.Kind}\u0022;\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\u0027s local axes while the result is applied in world space.\n\t\tusing var scope = Gizmo.Scope(\n\t\t\t$\u0022anchor_control:{anchor.Kind}\u0022,\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( \u0022anchor_rotate\u0022, 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$\u0022Rotate {anchor.Name} anchor\u0022,\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 =\u003E\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\u0026\u0026 Gizmo.Control.Position( \u0022anchor_move\u0022, Vector3.Zero, out var moveDelta, basis ) )\n\t\t{\n\t\t\tBeginCalibrationGizmoDrag(\n\t\t\t\ttoken,\n\t\t\t\t$\u0022Move {anchor.Name} anchor\u0022,\n\t\t\t\tliveWorld,\n\t\t\t\tnew Transform( anchor.LocalPosition, anchor.LocalRotation ) );\n\t\t\t_calibrationGizmoMoveDelta \u002B= 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 =\u003E\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 ? \u0022rig:framing\u0022 : \u0022rig:physical\u0022;\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\u0022whole_rig\u0022,\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( \u0022rig_rotate\u0022, Rotation.Identity, out var delta ) )\n\t\t\t{\n\t\t\t\tBeginCalibrationGizmoDrag( token, \u0022Refine whole-rig rotation\u0022, 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 =\u003E\n\t\t\t\t\tSetRigTransform( d, framing, target =\u003E target.WithRotation( rotation ) ) );\n\t\t\t}\n\t\t}\n\t\telse if ( TransformMode == WeaponAnimatorTransformMode.Move\n\t\t\t\u0026\u0026 Gizmo.Control.Position( \u0022rig_move\u0022, Vector3.Zero, out var moveDelta, basis ) )\n\t\t{\n\t\t\tBeginCalibrationGizmoDrag( token, \u0022Refine whole-rig position\u0022, live, live );\n\t\t\t_calibrationGizmoMoveDelta \u002B= 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 =\u003E\n\t\t\t\tSetRigTransform( d, framing, target =\u003E target.WithPosition( position ) ) );\n\t\t}\n\t\telse if ( TransformMode == WeaponAnimatorTransformMode.Scale\n\t\t\t\u0026\u0026 Gizmo.Control.Scale( \u0022rig_scale\u0022, Vector3.Zero, out var scaleDelta, basis ) )\n\t\t{\n\t\t\tBeginCalibrationGizmoDrag( token, \u0022Refine whole-rig scale\u0022, live, live );\n\t\t\t_calibrationGizmoScaleDelta \u002B= 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 \u002B 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 =\u003E\n\t\t\t{\n\t\t\t\tSetRigTransform( d, framing, target =\u003E 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 ) \u003E MathF.Abs( dominant ) )\n\t\t\tdominant = value.y;\n\t\tif ( MathF.Abs( value.z ) \u003E 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 ) =\u003E\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\u003CTransform, Transform\u003E 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 ) =\u003E\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 ) \u0026\u0026 _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 = \u0022\u0022;\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( $\u0022animate:{context.Target}\u0022, 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( \u0022rotate\u0022, 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\u0026\u0026 Gizmo.Control.Position( \u0022move\u0022, Vector3.Zero, out var delta, basis ) )\n\t\t{\n\t\t\tBeginAnimationGizmoDrag( context );\n\t\t\t_animationGizmoMoveDelta \u002B= 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\u0026\u0026 Gizmo.Control.Scale( \u0022scale\u0022, Vector3.Zero, out var scaleDelta, basis ) )\n\t\t{\n\t\t\tBeginAnimationGizmoDrag( context );\n\t\t\t_animationGizmoScaleDelta \u002B= 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\u0026\u0026 _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$\u0022{TransformModeName( TransformMode )} {context.Target}\u0022 );\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 = \u0022\u0022;\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 ) =\u003E\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 \u002B movement;\n\n\t\treturn Gizmo.Snap( start, movement, localSpace );\n\t}\n\n\tinternal static Transform WorldToLocal( Transform world, Transform? parent ) =\u003E\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 \u002B 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 ) =\u003E\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$\u0022{measurement.FirstPoint.Distance( measurement.SecondPoint ):0.###} source units\u0022,\n\t\t\t(a \u002B 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\u002B _camera.WorldRotation.Right * labelOffset.x * markerScale\n\t\t\t\t\u002B _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$\u0022[{AnchorCode( anchor.Kind )}] {CalibrationSelection.DisplayName( anchor ).ToUpperInvariant()}\u0022,\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$\u0022anchor:{anchor.Id:N}\u0022,\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 \u0026\u0026 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 \u003E 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( \u0022\u002B\u0022, 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? \u0022VIEWMODEL CAMERA\u0022\n\t\t\t: document.Workspace.FreeLookCamera\n\t\t\t\t? \u0022FREE LOOK\u0022\n\t\t\t\t: \u0022ORBIT\u0022;\n\t\tGizmo.Draw.ScreenText(\n\t\t\t$\u0022{mode} \u00B7 {document.Calibration.AspectGuide} \u00B7 {document.Calibration.HorizontalFov:0}\u00B0 HFOV\u0022,\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 = \u0022Inter\u0022,\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 \u002B 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 \u003E= 1.8f )\n\t\t\treturn;\n\n\t\tvar elapsed = (float)_sinceCameraSpeedChanged;\n\t\tvar alpha = elapsed \u003C= 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 \u003E= 720 ? 10 : Width \u003E= 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 = $\u0022CAMERA SPEED  {_controller.Document.Workspace.CameraMoveSpeed:0.##}\u00D7\u0022,\n\t\t\tTextColor = WeaponAnimatorTheme.Text.WithAlpha( 0.9f * alpha ),\n\t\t\tFontSize = 10 * global::Editor.Application.DpiScale,\n\t\t\tFontName = \u0022Inter\u0022,\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 = \u0022\u0022;\n\t\t_controller.Mutate( $\u0022Set {PickLabel( mode )}\u0022, document =\u003E\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( $\u0022{PickLabel( mode )} set at {localPosition}.\u0022 );\n\t}\n\n\tprivate static AnchorKind PickAnchorKind( ViewportPickMode mode ) =\u003E mode switch\n\t{\n\t\tViewportPickMode.GripAnchor =\u003E AnchorKind.Grip,\n\t\tViewportPickMode.RearBoreAnchor =\u003E AnchorKind.RearBore,\n\t\tViewportPickMode.FrontBoreAnchor =\u003E AnchorKind.FrontBore,\n\t\tViewportPickMode.MuzzleAnchor =\u003E AnchorKind.Muzzle,\n\t\tViewportPickMode.EjectAnchor =\u003E AnchorKind.Eject,\n\t\t_ =\u003E AnchorKind.Custom\n\t};\n\n\tprivate static string PickLabel( ViewportPickMode mode ) =\u003E mode switch\n\t{\n\t\tViewportPickMode.MeasurementFirst =\u003E \u0022measurement point A\u0022,\n\t\tViewportPickMode.MeasurementSecond =\u003E \u0022measurement point B\u0022,\n\t\tViewportPickMode.GripAnchor =\u003E \u0022primary grip\u0022,\n\t\tViewportPickMode.RearBoreAnchor =\u003E \u0022alignment marker \u2014 rear\u0022,\n\t\tViewportPickMode.FrontBoreAnchor =\u003E \u0022alignment marker \u2014 front\u0022,\n\t\tViewportPickMode.MuzzleAnchor =\u003E \u0022muzzle\u0022,\n\t\tViewportPickMode.EjectAnchor =\u003E \u0022eject\u0022,\n\t\tViewportPickMode.CustomAnchor =\u003E \u0022custom anchor\u0022,\n\t\t_ =\u003E \u0022point\u0022\n\t};\n\n\tprivate static Color AnchorColor( AnchorKind kind ) =\u003E kind switch\n\t{\n\t\tAnchorKind.Grip =\u003E WeaponAnimatorTheme.Cyan,\n\t\tAnchorKind.RearBore =\u003E new Color( 0.64f, 0.48f, 0.95f ),\n\t\tAnchorKind.FrontBore =\u003E WeaponAnimatorTheme.Amber,\n\t\tAnchorKind.Muzzle =\u003E WeaponAnimatorTheme.Coral,\n\t\tAnchorKind.Eject =\u003E WeaponAnimatorTheme.Green,\n\t\t_ =\u003E Color.White\n\t};\n\n\tprivate static Vector2 AnchorLabelOffset( AnchorKind kind ) =\u003E kind switch\n\t{\n\t\tAnchorKind.Grip =\u003E new Vector2( -1.8f, 1.1f ),\n\t\tAnchorKind.RearBore =\u003E new Vector2( 1.5f, 1.7f ),\n\t\tAnchorKind.FrontBore =\u003E new Vector2( 1.7f, 0.8f ),\n\t\tAnchorKind.Muzzle =\u003E new Vector2( 2.1f, -0.5f ),\n\t\tAnchorKind.Eject =\u003E new Vector2( -1.7f, 1.8f ),\n\t\t_ =\u003E new Vector2( 1.5f, 1.0f )\n\t};\n\n\tprivate static string AnchorCode( AnchorKind kind ) =\u003E kind switch\n\t{\n\t\tAnchorKind.Grip =\u003E \u0022G\u0022,\n\t\tAnchorKind.RearBore =\u003E \u0022AR\u0022,\n\t\tAnchorKind.FrontBore =\u003E \u0022AF\u0022,\n\t\tAnchorKind.Muzzle =\u003E \u0022M\u0022,\n\t\tAnchorKind.Eject =\u003E \u0022E\u0022,\n\t\t_ =\u003E \u0022A\u0022\n\t};\n\n\tprivate static float GuideAspect( string guide ) =\u003E guide switch\n\t{\n\t\t\u00224:3\u0022 =\u003E 4.0f / 3.0f,\n\t\t\u002221:9\u0022 =\u003E 21.0f / 9.0f,\n\t\t_ =\u003E 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 =\u003E\n\t\t\tx.PartId == part.Id \u0026\u0026 !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 \u003E time \u002B 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 =\u003E\n\t\t\tMathF.Abs( x.Time - time ) \u003C= 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 ) =\u003E a.Time.CompareTo( b.Time ) );\n\t\treturn key;\n\t}\n\n\tpublic static string VisibleTag( Guid partId ) =\u003E\n\t\t$\u0022wepanim_part_{partId:N}_visible\u0022;\n\n\tpublic static string HiddenTag( Guid partId ) =\u003E\n\t\t$\u0022wepanim_part_{partId:N}_hidden\u0022;\n\n\tpublic static IReadOnlyList\u003CWeaponVisibilitySpan\u003E 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 =\u003E x.PartId == part.Id \u0026\u0026 !x.Muted )?\n\t\t\t.Keys\n\t\t\t.Where( x =\u003E x.Time \u003E= 0 \u0026\u0026 x.Time \u003C= duration \u002B TimeTolerance )\n\t\t\t.OrderBy( x =\u003E x.Time )\n\t\t\t.ToArray() ?? [];\n\t\tvar result = new List\u003CWeaponVisibilitySpan\u003E();\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 \u003E start \u002B 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 \u003C 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 ) =\u003E 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\u0022\u003C!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} \u0022 \u002B\n\t\t\u0022format:animgraph2:version{0f7898b8-5471-45c4-9867-cd9c46bcfdb5} --\u003E\u0022;\n\n\tprivate sealed record StateSpec(\n\t\tstring Name,\n\t\tWeaponClipRole Role,\n\t\tbool Loop,\n\t\tList\u003Cstring\u003E 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 =\u003E x.Role == WeaponClipRole.Idle );\n\t\tWeaponAnimationClip ClipFor( WeaponClipRole role )\n\t\t{\n\t\t\tvar clip = document.Clips.FirstOrDefault( x =\u003E x.Role == role );\n\t\t\treturn clip is not null \u0026\u0026 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$\u0022seq_{state.Name}\u0022,\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 \u002B= 144;\n\t\t}\n\n\t\tnodes.AppendLine( StateMachineNode( states ) );\n\t\tnodes.AppendLine( RootNode() );\n\n\t\tvar parameters = string.Join( \u0022\\n\u0022, ParameterDefinitions() );\n\t\tvar tags = string.Join( \u0022\\n\u0022, StandardTags( document ) );\n\t\treturn $$\u0022\u0022\u0022\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 = \u0022CAnimationGraph\u0022\n\t\t\t\tm_nodeManager =\n\t\t\t\t{\n\t\t\t\t\t_class = \u0022CAnimNodeManager\u0022\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 = \u0022CAnimParameterList\u0022\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 = \u0022CAnimTagManager\u0022\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 = \u0022CAnimMovementManager\u0022\n\t\t\t\t\tm_MotorList = { _class = \u0022CAnimMotorList\u0022 m_motors = [ ] }\n\t\t\t\t\tm_MovementSettings =\n\t\t\t\t\t{\n\t\t\t\t\t\t_class = \u0022CAnimMovementSettings\u0022\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 = \u0022CAnimGraphSettingsManager\u0022\n\t\t\t\t\tm_settingsGroups =\n\t\t\t\t\t[\n\t\t\t\t\t\t{ _class = \u0022CAnimGraphGeneralSettings\u0022 m_iGridSnap = 16 },\n\t\t\t\t\t]\n\t\t\t\t}\n\t\t\t\tm_pActivityValuesList = { _class = \u0022CActivityValueList\u0022 m_activities = [ ] }\n\t\t\t\tm_previewModels = [ \u0022{{hostModelPath}}\u0022, ]\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 = \u0022{{HostSkeletonBuilder.ProductionArmsModel}}\u0022\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 = \u0022camera\u0022\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\u0022\u0022\u0022;\n\t}\n\n\tprivate static List\u003CStateSpec\u003E BuildStates( WeaponAnimationDocument document )\n\t{\n\t\tvar idleTransitions = new List\u003Cstring\u003E\n\t\t{\n\t\t\tTransition( [BoolCondition( \u0022b_attack_dry\u0022, true )], \u0022FireDry\u0022, 0.02f ),\n\t\t\tTransition( [BoolCondition( \u0022b_attack\u0022, true )], \u0022Fire\u0022, 0.02f ),\n\t\t\tTransition(\n\t\t\t\t[BoolCondition( \u0022b_reload\u0022, true ), BoolCondition( \u0022b_empty\u0022, true )],\n\t\t\t\t\u0022ReloadEmpty\u0022,\n\t\t\t\t0.05f ),\n\t\t\tTransition( [BoolCondition( \u0022b_reload\u0022, true )], \u0022Reload\u0022, 0.05f ),\n\t\t\tTransition( [BoolCondition( \u0022b_deploy\u0022, true )], \u0022Deploy\u0022, 0.05f ),\n\t\t\tTransition( [BoolCondition( \u0022b_holster\u0022, true )], \u0022Holster\u0022, 0.05f ),\n\t\t\tTransition( [BoolCondition( \u0022b_inspect\u0022, true )], \u0022Inspect\u0022, 0.08f ),\n\t\t\tTransition( [BoolCondition( \u0022b_sprint\u0022, true )], \u0022Sprint\u0022, 0.08f ),\n\t\t\tTransition( [BoolCondition( \u0022b_jump\u0022, true )], \u0022Jump\u0022, 0.05f ),\n\t\t\tTransition( [BoolCondition( \u0022b_lower_weapon\u0022, true )], \u0022Lower\u0022, 0.08f ),\n\t\t\tTransition( [IntCondition( \u0022ironsights\u0022, 1 )], \u0022Ironsights\u0022, 0.08f ),\n\t\t\tTransition( [BoolCondition( \u0022b_grab\u0022, true )], \u0022GrabStance\u0022, 0.08f ),\n\t\t\tTransition( [IntCondition( \u0022grab_action\u0022, 1 )], \u0022GrabGesture1\u0022, 0.04f ),\n\t\t\tTransition( [IntCondition( \u0022grab_action\u0022, 2 )], \u0022GrabGesture2\u0022, 0.04f ),\n\t\t\tTransition( [IntCondition( \u0022grab_action\u0022, 3 )], \u0022GrabGesture3\u0022, 0.04f ),\n\t\t\tTransition( [IntCondition( \u0022grab_action\u0022, 4 )], \u0022GrabGesture4\u0022, 0.04f )\n\t\t};\n\n\t\tif ( document.Graph.ReloadProfile == ReloadProfile.Incremental )\n\t\t\tidleTransitions.Insert( 3, Transition( [BoolCondition( \u0022b_reloading\u0022, true )], \u0022ReloadEnter\u0022, 0.05f ) );\n\n\t\tvar finishedToIdle = new List\u003Cstring\u003E { Transition( [FinishedCondition()], \u0022Idle\u0022, 0.08f ) };\n\t\tvar states = new List\u003CStateSpec\u003E\n\t\t{\n\t\t\tnew( \u0022Idle\u0022, WeaponClipRole.Idle, true, idleTransitions, true ),\n\t\t\tnew( \u0022Deploy\u0022, WeaponClipRole.Deploy, false, finishedToIdle ),\n\t\t\tnew( \u0022Fire\u0022, WeaponClipRole.Fire, false, [\n\t\t\t\tTransition( [BoolCondition( \u0022b_attack\u0022, true )], \u0022Fire\u0022, 0.01f ),\n\t\t\t\tTransition( [FinishedCondition()], \u0022Idle\u0022, 0.06f )\n\t\t\t] ),\n\t\t\tnew( \u0022FireDry\u0022, WeaponClipRole.FireDry, false, finishedToIdle ),\n\t\t\tnew( \u0022Reload\u0022, WeaponClipRole.Reload, false, finishedToIdle ),\n\t\t\tnew( \u0022ReloadEmpty\u0022, WeaponClipRole.ReloadEmpty, false, finishedToIdle ),\n\t\t\tnew( \u0022Holster\u0022, WeaponClipRole.Holster, false, [] ),\n\t\t\tnew( \u0022Inspect\u0022, WeaponClipRole.Inspect, false, finishedToIdle ),\n\t\t\tnew( \u0022Sprint\u0022, WeaponClipRole.Sprint, true, [\n\t\t\t\tTransition( [BoolCondition( \u0022b_sprint\u0022, false )], \u0022Idle\u0022, 0.08f, false )\n\t\t\t] ),\n\t\t\tnew( \u0022Jump\u0022, WeaponClipRole.Jump, false, finishedToIdle ),\n\t\t\tnew( \u0022Lower\u0022, WeaponClipRole.Lower, true, [\n\t\t\t\tTransition( [BoolCondition( \u0022b_lower_weapon\u0022, false )], \u0022Idle\u0022, 0.08f, false )\n\t\t\t] ),\n\t\t\tnew( \u0022Ironsights\u0022, WeaponClipRole.Ironsights, true, [\n\t\t\t\tTransition( [IntCondition( \u0022ironsights\u0022, 0 )], \u0022Idle\u0022, 0.08f, false )\n\t\t\t] ),\n\t\t\tnew( \u0022GrabStance\u0022, WeaponClipRole.GrabStance, true, [\n\t\t\t\tTransition( [BoolCondition( \u0022b_grab\u0022, false )], \u0022Idle\u0022, 0.08f, false )\n\t\t\t] ),\n\t\t\tnew( \u0022GrabGesture1\u0022, WeaponClipRole.GrabGestureOne, false, finishedToIdle ),\n\t\t\tnew( \u0022GrabGesture2\u0022, WeaponClipRole.GrabGestureTwo, false, finishedToIdle ),\n\t\t\tnew( \u0022GrabGesture3\u0022, WeaponClipRole.GrabGestureThree, false, finishedToIdle ),\n\t\t\tnew( \u0022GrabGesture4\u0022, 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( \u0022ReloadEnter\u0022, WeaponClipRole.ReloadEnter, false, [\n\t\t\t\t\tTransition( [FinishedCondition()], \u0022FirstShell\u0022, 0.04f )\n\t\t\t\t] ),\n\t\t\t\tnew( \u0022FirstShell\u0022, WeaponClipRole.FirstShell, false, [\n\t\t\t\t\tTransition( [BoolCondition( \u0022b_reloading\u0022, false )], \u0022ReloadExit\u0022, 0.04f ),\n\t\t\t\t\tTransition( [FinishedCondition()], \u0022InsertShell\u0022, 0.04f )\n\t\t\t\t] ),\n\t\t\t\tnew( \u0022InsertShell\u0022, WeaponClipRole.InsertShell, false, [\n\t\t\t\t\tTransition( [BoolCondition( \u0022b_reloading\u0022, false )], \u0022ReloadExit\u0022, 0.04f ),\n\t\t\t\t\tTransition( [FinishedCondition()], \u0022InsertShell\u0022, 0.02f )\n\t\t\t\t] ),\n\t\t\t\tnew( \u0022ReloadExit\u0022, 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\u003CWeaponVisibilityPart\u003E visibilityParts,\n\t\tbool loop,\n\t\tfloat x,\n\t\tfloat y )\n\t{\n\t\tvar id = Id( $\u0022node:{name}\u0022 );\n\t\tvar visibilityTags = visibilityParts.SelectMany( part =\u003E\n\t\t\tWeaponVisibilityEvaluator.BuildSpans( part, clip ).Select( span =\u003E\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( \u0022\\n\u0022, clip.Tags\n\t\t\t.Concat( visibilityTags )\n\t\t\t.Where( x =\u003E !string.IsNullOrWhiteSpace( x.Name ) )\n\t\t\t.OrderBy( x =\u003E x.StartTime )\n\t\t\t.ThenBy( x =\u003E x.Name )\n\t\t\t.Select( x =\u003E TagSpan( x, clip ) ) );\n\t\treturn $$\u0022\u0022\u0022\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 = \u0022CSequenceAnimNode\u0022\n\t\t\t\t\t\t\t\tm_sName = \u0022{{name}}\u0022\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 = \u0022\u0022\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 = \u0022{{sequence}}\u0022\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\u0022\u0022\u0022;\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 $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t_class = \u0022CAnimTagSpan\u0022\n\t\t\t\t\t\t\t\t\t\tm_id = { m_id = {{Id( $\u0022tag:{tag.Name}\u0022 )}} }\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\u0022\u0022\u0022;\n\t}\n\n\tprivate static string StateMachineNode( IEnumerable\u003CStateSpec\u003E states )\n\t{\n\t\tvar stateText = string.Join( \u0022\\n\u0022, states.Select( StateNode ) );\n\t\tvar id = Id( \u0022node:StateMachine\u0022 );\n\t\treturn $$\u0022\u0022\u0022\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 = \u0022CStateMachineAnimNode\u0022\n\t\t\t\t\t\t\t\tm_sName = \u0022Weapon States\u0022\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 = \u0022\u0022\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\u0022\u0022\u0022;\n\t}\n\n\tprivate static string StateNode( StateSpec state )\n\t{\n\t\tvar transitions = string.Join( \u0022\\n\u0022, state.Transitions );\n\t\treturn $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t_class = \u0022CAnimState\u0022\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 = \u0022{{state.Name}}\u0022\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( $\u0022node:seq_{state.Name}\u0022 )}} }\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( $\u0022state:{state.Name}\u0022 )}} }\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\u0022\u0022\u0022;\n\t}\n\n\tprivate static string RootNode()\n\t{\n\t\tvar id = Id( \u0022node:Root\u0022 );\n\t\treturn $$\u0022\u0022\u0022\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 = \u0022CRootAnimNode\u0022\n\t\t\t\t\t\t\t\tm_sName = \u0022Output\u0022\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 = \u0022\u0022\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( \u0022node:StateMachine\u0022 )}} }\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\u0022\u0022\u0022;\n\t}\n\n\tprivate static string Transition(\n\t\tIEnumerable\u003Cstring\u003E conditions,\n\t\tstring destination,\n\t\tfloat blend,\n\t\tbool reset = true )\n\t{\n\t\tvar conditionText = string.Join( \u0022\\n\u0022, conditions );\n\t\treturn $$\u0022\u0022\u0022\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 = \u0022CAnimStateTransition\u0022\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( $\u0022state:{destination}\u0022 )}} }\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 = \u0022Beginning\u0022\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\u0022\u0022\u0022;\n\t}\n\n\tprivate static string BoolCondition( string name, bool value ) =\u003E $$\u0022\u0022\u0022\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 = \u0022CParameterAnimCondition\u0022\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( $\u0022param:{name}\u0022 )}} }\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\u0022\u0022\u0022;\n\n\tprivate static string IntCondition( string name, int value ) =\u003E $$\u0022\u0022\u0022\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 = \u0022CParameterAnimCondition\u0022\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( $\u0022param:{name}\u0022 )}} }\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\u0022\u0022\u0022;\n\n\tprivate static string FinishedCondition() =\u003E \u0022\u0022\u0022\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 = \u0022CFinishedCondition\u0022\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 = \u0022FinishedConditionOption_OnAlmostFinished\u0022\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\u0022\u0022\u0022;\n\n\tprivate static IEnumerable\u003Cstring\u003E ParameterDefinitions()\n\t{\n\t\tvar pulseBools = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\t\u0022b_attack\u0022, \u0022b_attack_dry\u0022, \u0022b_jump\u0022, \u0022b_reload\u0022, \u0022b_deploy\u0022, \u0022b_inspect\u0022,\n\t\t\t\u0022b_reloading_shell\u0022, \u0022b_reloading_first_shell\u0022\n\t\t};\n\t\tvar bools = new[]\n\t\t{\n\t\t\t\u0022b_grounded\u0022, \u0022b_jump\u0022, \u0022b_sprint\u0022, \u0022b_attack\u0022, \u0022b_attack_dry\u0022, \u0022b_attack_has_hit\u0022,\n\t\t\t\u0022b_reload\u0022, \u0022b_empty\u0022, \u0022b_deploy\u0022, \u0022b_deploy_skip\u0022, \u0022b_deploy_first\u0022,\n\t\t\t\u0022b_twohanded\u0022, \u0022b_lower_weapon\u0022, \u0022b_holster\u0022, \u0022b_grab\u0022, \u0022b_inspect\u0022,\n\t\t\t\u0022b_reloading\u0022, \u0022b_reloading_shell\u0022, \u0022b_reloading_first_shell\u0022\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(\u0022move_bob\u0022, 0, 0, 1),\n\t\t\t(\u0022move_bob_cycle_control\u0022, 0, 0, 1),\n\t\t\t(\u0022move_x\u0022, 0, -1, 1),\n\t\t\t(\u0022move_y\u0022, 0, -1, 1),\n\t\t\t(\u0022move_z\u0022, 0, -1, 1),\n\t\t\t(\u0022attack_hold\u0022, 0, 0, 1),\n\t\t\t(\u0022ironsights_fire_scale\u0022, 0, 0, 1),\n\t\t\t(\u0022camera_position_scale\u0022, 1, 0, 2),\n\t\t\t(\u0022camera_rotation_scale\u0022, 1, 0, 2),\n\t\t\t(\u0022speed_reload\u0022, 1, 0.05f, 5),\n\t\t\t(\u0022speed_deploy\u0022, 1, 0.05f, 5),\n\t\t\t(\u0022speed_ironsights\u0022, 1, 0.05f, 5),\n\t\t\t(\u0022speed_grab\u0022, 1, 0.05f, 5),\n\t\t\t(\u0022aim_pitch_inertia\u0022, 0, -45, 45),\n\t\t\t(\u0022aim_yaw_inertia\u0022, 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( \u0022ironsights\u0022, [\u0022Hip\u0022, \u0022ADS\u0022] );\n\t\tyield return EnumParameter( \u0022firing_mode\u0022, [\u0022Safe\u0022, \u0022Single\u0022, \u0022Burst\u0022, \u0022Automatic\u0022] );\n\t\tyield return EnumParameter( \u0022weapon_pose\u0022, [\u0022Default\u0022, \u0022Alternate\u0022] );\n\t\tyield return EnumParameter( \u0022grab_action\u0022, [\u0022None\u0022, \u0022Sweep Down\u0022, \u0022Sweep Right\u0022, \u0022Sweep Left\u0022, \u0022Push\u0022] );\n\t\tyield return EnumParameter( \u0022deploy_type\u0022, [\u0022Default\u0022, \u0022Alternate\u0022] );\n\t\tyield return EnumParameter( \u0022reload_type\u0022, [\u0022Default\u0022, \u0022Alternate\u0022] );\n\t\tyield return EnumParameter( \u0022skeleton\u0022, [\u0022Human\u0022, \u0022Citizen\u0022] );\n\t}\n\n\tprivate static string BoolParameter( string name, bool autoReset ) =\u003E $$\u0022\u0022\u0022\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \u0022CBoolAnimParameter\u0022\n\t\t\t\t\t\t\tm_name = \u0022{{name}}\u0022\n\t\t\t\t\t\t\tm_id = { m_id = {{Id( $\u0022param:{name}\u0022 )}} }\n\t\t\t\t\t\t\tm_previewButton = \u0022ANIMPARAM_BUTTON_NONE\u0022\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\u0022\u0022\u0022;\n\n\tprivate static string FloatParameter( string name, float value, float min, float max ) =\u003E $$\u0022\u0022\u0022\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \u0022CFloatAnimParameter\u0022\n\t\t\t\t\t\t\tm_name = \u0022{{name}}\u0022\n\t\t\t\t\t\t\tm_id = { m_id = {{Id( $\u0022param:{name}\u0022 )}} }\n\t\t\t\t\t\t\tm_previewButton = \u0022ANIMPARAM_BUTTON_NONE\u0022\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\u0022\u0022\u0022;\n\n\tprivate static string EnumParameter( string name, IEnumerable\u003Cstring\u003E choices )\n\t{\n\t\tvar options = string.Join( \u0022\\n\u0022, choices.Select( x =\u003E $\u0022\\t\\t\\t\\t\\t\\t\\\u0022{x}\\\u0022,\u0022 ) );\n\t\treturn $$\u0022\u0022\u0022\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \u0022CEnumAnimParameter\u0022\n\t\t\t\t\t\t\tm_name = \u0022{{name}}\u0022\n\t\t\t\t\t\t\tm_id = { m_id = {{Id( $\u0022param:{name}\u0022 )}} }\n\t\t\t\t\t\t\tm_previewButton = \u0022ANIMPARAM_BUTTON_NONE\u0022\n\t\t\t\t\t\t\tm_bUseMostRecentValue = false\n\t\t\t\t\t\t\tm_bAutoReset = {{(name == \u0022grab_action\u0022).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\u0022\u0022\u0022;\n\t}\n\n\tprivate static IEnumerable\u003Cstring\u003E StandardTags( WeaponAnimationDocument document )\n\t{\n\t\tvar tags = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\t\u0022attack_discouraged\u0022,\n\t\t\t\u0022holster_finished\u0022,\n\t\t\t\u0022reload_bodygroup\u0022,\n\t\t\t\u0022reload_increment\u0022\n\t\t};\n\n\t\tforeach ( var tag in document.Clips.SelectMany( x =\u003E 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 =\u003E x ) )\n\t\t{\n\t\t\tyield return $$\u0022\u0022\u0022\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \u0022CStringAnimTag\u0022\n\t\t\t\t\t\t\tm_name = \u0022{{name}}\u0022\n\t\t\t\t\t\t\tm_tagID = { m_id = {{Id( $\u0022tag:{name}\u0022 )}} }\n\t\t\t\t\t\t},\n\t\t\t\u0022\u0022\u0022;\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 \u003C 8; bit\u002B\u002B )\n\t\t\t\tcrc = (crc \u0026 1) != 0 ? (crc \u003E\u003E 1) ^ 0xEDB88320 : crc \u003E\u003E 1;\n\t\t}\n\n\t\tvar result = (crc ^ 0xFFFFFFFF) \u0026 0x7FFFFFFF;\n\t\treturn result == 0 ? 1u : result;\n\t}\n\n\tprivate static string F( float value ) =\u003E\n\t\tvalue.ToString( \u00220.######\u0022, 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\u003Cstring\u003E ExcludedBranchRoots,\n\tIReadOnlyList\u003CHostMaterialRemap\u003E? 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\u0022\u003C!-- kv3 encoding:text:version{e21c7f3c-8a33-41c5-9977-a76d3a32aa0d} \u0022 \u002B\n\t\t\u0022format:modeldoc29:version{3cec427c-1b0e-4d48-a90a-0436f33a6041} --\u003E\u0022;\n\n\tpublic static string WriteHost(\n\t\tstring referenceMesh,\n\t\tIEnumerable\u003C(WeaponAnimationClip Clip, string Source)\u003E clips,\n\t\tstring animGraphPath,\n\t\tIEnumerable\u003Cstring\u003E preservedBones,\n\t\tHostWeaponMesh? weaponMesh = null,\n\t\tIEnumerable\u003CHostAttachment\u003E? attachments = null,\n\t\tstring baseModelPath = \u0022\u0022,\n\t\tIEnumerable\u003CHostMaterialRemap\u003E? baseMaterialRemaps = null )\n\t{\n\t\tvar animationNodes = new StringBuilder();\n\t\tforeach ( var item in clips.OrderBy( x =\u003E x.Clip.Name ) )\n\t\t{\n\t\t\tanimationNodes.AppendLine( $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \u0022AnimFile\u0022\n\t\t\t\t\t\t\t\t\tname = \u0022{{WeaponAnimationNames.SequenceName( item.Clip )}}\u0022\n\t\t\t\t\t\t\t\t\tactivity_name = \u0022\u0022\n\t\t\t\t\t\t\t\t\tactivity_weight = 1\n\t\t\t\t\t\t\t\t\tweight_list_name = \u0022\u0022\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 = \u0022{{item.Source}}\u0022\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\u0022\u0022\u0022 );\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 =\u003E !string.IsNullOrWhiteSpace( name ) )\n\t\t\t.Distinct( System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( name =\u003E name, System.StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tboneMarkupNodes.AppendLine( $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t_class = \u0022BoneMarkup\u0022\n\t\t\t\t\t\t\t\t\t\t\ttarget_bone = \u0022{{Escape( boneName )}}\u0022\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\u0022\u0022\u0022 );\n\t\t}\n\n\t\treturn $$\u0022\u0022\u0022\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 = \u0022RootNode\u0022\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 = \u0022MaterialGroupList\u0022\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 = \u0022RenderMeshList\u0022\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 = \u0022RenderMeshFile\u0022\n\t\t\t\t\t\t\t\t\tname = \u0022animation_host\u0022\n\t\t\t\t\t\t\t\t\tfilename = \u0022{{referenceMesh}}\u0022\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 = \u0022None\u0022\n\t\t\t\t\t\t\t\t\talign_origin_y_type = \u0022None\u0022\n\t\t\t\t\t\t\t\t\talign_origin_z_type = \u0022None\u0022\n\t\t\t\t\t\t\t\t\tparent_bone = \u0022\u0022\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 = \u0022AnimationList\u0022\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 = \u0022\u0022\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 = \u0022BoneMarkupList\u0022\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 = \u0022None\u0022\n\t\t\t\t\t\t},\n\t\t\t\t\t]\n\t\t\t\t\tmodel_archetype = \u0022\u0022\n\t\t\t\t\tprimary_associated_entity = \u0022\u0022\n\t\t\t\t\tanim_graph_name = \u0022{{animGraphPath}}\u0022\n\t\t\t\t\tbase_model_name = \u0022{{Escape( baseModelPath )}}\u0022\n\t\t\t\t}\n\t\t\t}\n\t\t\t\u0022\u0022\u0022;\n\t}\n\n\tprivate static string BuildMaterialGroup(\n\t\tbool includesImportedWeapon,\n\t\tIEnumerable\u003CHostMaterialRemap\u003E? materialRemaps )\n\t{\n\t\tif ( !includesImportedWeapon )\n\t\t{\n\t\t\treturn \u0022\u0022\u0022\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \u0022DefaultMaterialGroup\u0022\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 = \u0022materials/default.vmat\u0022\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\u0022\u0022\u0022;\n\t\t}\n\n\t\tvar remaps = new List\u003CHostMaterialRemap\u003E\n\t\t{\n\t\t\tnew(\n\t\t\t\t\u0022materials/tools/toolsinvisible.vmat\u0022,\n\t\t\t\t\u0022materials/tools/toolsinvisible.vmat\u0022 )\n\t\t};\n\t\tremaps.AddRange( materialRemaps?\n\t\t\t.Where( remap =\u003E !string.IsNullOrWhiteSpace( remap.SourceMaterial )\n\t\t\t\t\u0026\u0026 !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 =\u003E remap.SourceMaterial,\n\t\t\t\tSystem.StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy(\n\t\t\t\tremap =\u003E remap.SourceMaterial,\n\t\t\t\tSystem.StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tremapText.AppendLine( $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tfrom = \u0022{{Escape( remap.SourceMaterial )}}\u0022\n\t\t\t\t\t\t\t\t\t\t\tto = \u0022{{Escape( remap.TargetMaterial )}}\u0022\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\u0022\u0022\u0022 );\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 $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \u0022DefaultMaterialGroup\u0022\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 = \u0022materials/default.vmat\u0022\n\t\t\t\t\t\t\t},\n\t\t\t\u0022\u0022\u0022;\n\t}\n\n\tprivate static string BuildAttachmentList( IEnumerable\u003CHostAttachment\u003E? attachments )\n\t{\n\t\tvar items = attachments?\n\t\t\t.Where( x =\u003E !string.IsNullOrWhiteSpace( x.Name )\n\t\t\t\t\u0026\u0026 !string.IsNullOrWhiteSpace( x.ParentBone ) )\n\t\t\t.OrderBy( x =\u003E x.Name, System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray() ?? [];\n\t\tif ( items.Length == 0 )\n\t\t\treturn \u0022\u0022;\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( $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \u0022Attachment\u0022\n\t\t\t\t\t\t\t\t\tname = \u0022{{Escape( attachment.Name )}}\u0022\n\t\t\t\t\t\t\t\t\tparent_bone = \u0022{{Escape( attachment.ParentBone )}}\u0022\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\u0022\u0022\u0022 );\n\t\t}\n\n\t\treturn $$\u0022\u0022\u0022\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t_class = \u0022AttachmentList\u0022\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\u0022\u0022\u0022;\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 \u0022\u0022;\n\n\t\tvar modifiers = new StringBuilder();\n\t\tif ( !string.IsNullOrWhiteSpace( source.SourceRootBoneName )\n\t\t\t\u0026\u0026 !source.SourceRootBoneName.Equals(\n\t\t\t\t\u0022weapon_root\u0022,\n\t\t\t\tSystem.StringComparison.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tmodifiers.AppendLine( $$\u0022\u0022\u0022\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 = \u0022RenameBonePrefix\u0022\n\t\t\t\t\t\t\t\t\t\t\t\tprefix_to_match = \u0022{{Escape( source.SourceRootBoneName )}}\u0022\n\t\t\t\t\t\t\t\t\t\t\t\treplacement = \u0022weapon_root\u0022\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\u0022\u0022\u0022 );\n\t\t}\n\n\t\tvar excluded = source.ExcludedBranchRoots\n\t\t\t.Where( x =\u003E !string.IsNullOrWhiteSpace( x ) )\n\t\t\t.Distinct( System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( x =\u003E x, System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray();\n\t\tif ( excluded.Length \u003E 0 )\n\t\t{\n\t\t\tvar names = string.Join(\n\t\t\t\t\u0022\\n\u0022,\n\t\t\t\texcluded.Select( x =\u003E $\u0022\\t\\t\\t\\t\\t\\t\\t\\t\\t\\t\\t\\\u0022{Escape( x )}\\\u0022,\u0022 ) );\n\t\t\tmodifiers.AppendLine( $$\u0022\u0022\u0022\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 = \u0022RemoveBoneAndChildren\u0022\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\u0022\u0022\u0022 );\n\t\t}\n\n\t\tvar children = modifiers.Length == 0\n\t\t\t? \u0022\u0022\n\t\t\t: $$\u0022\u0022\u0022\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\u0022\u0022\u0022;\n\t\tvar angles = source.ImportTransform.Rotation.Angles();\n\t\treturn $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t_class = \u0022RenderMeshFile\u0022\n\t\t\t\t\t\t\t\t\tname = \u0022weapon\u0022\n\t\t\t\t\t\t\t\t\tfilename = \u0022{{Escape( source.SourcePath )}}\u0022\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 = \u0022None\u0022\n\t\t\t\t\t\t\t\t\talign_origin_y_type = \u0022None\u0022\n\t\t\t\t\t\t\t\t\talign_origin_z_type = \u0022None\u0022\n\t\t\t\t\t\t\t\t\tparent_bone = \u0022\u0022\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\u0022\u0022\u0022;\n\t}\n\n\tpublic static string WriteSourceWrapper(\n\t\tstring sourcePath,\n\t\tstring sourceRootBoneName = \u0022\u0022,\n\t\tSystem.Collections.Generic.IEnumerable\u003Cstring\u003E? excludedBranchRoots = null,\n\t\tSystem.Collections.Generic.IEnumerable\u003CHostMaterialRemap\u003E? 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 $$\u0022\u0022\u0022\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 = \u0022RootNode\u0022\n\t\t\t\tchildren =\n\t\t\t\t[\n\t\t\t\t\t{\n\t\t\t\t\t\t_class = \u0022MaterialGroupList\u0022\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 = \u0022RenderMeshList\u0022\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 = \u0022RenderMeshFile\u0022\n\t\t\t\t\t\t\t\tname = \u0022source_weapon\u0022\n\t\t\t\t\t\t\t\tfilename = \u0022{{sourcePath}}\u0022\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 = \u0022None\u0022\n\t\t\t\t\t\t\t\talign_origin_y_type = \u0022None\u0022\n\t\t\t\t\t\t\t\talign_origin_z_type = \u0022None\u0022\n\t\t\t\t\t\t\t\tparent_bone = \u0022\u0022\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 = \u0022BoneMarkupList\u0022 bone_cull_type = \u0022None\u0022 },\n\t\t\t{{modifierList}}\t\t\t\n\t\t\t\t\t]\n\t\t\t\t\tmodel_archetype = \u0022\u0022\n\t\t\t\tprimary_associated_entity = \u0022\u0022\n\t\t\t\tanim_graph_name = \u0022\u0022\n\t\t\t\tbase_model_name = \u0022\u0022\n\t\t\t\t}\n\t\t\t}\n\t\t\t\u0022\u0022\u0022;\n\t}\n\n\tpublic static string WriteVmdlSourceAdapter(\n\t\tstring sourceModelDoc,\n\t\tstring sourceRootBoneName,\n\t\tSystem.Collections.Generic.IEnumerable\u003Cstring\u003E? 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( \u0022rootNode\u0022, System.StringComparison.Ordinal );\n\t\tvar childrenIndex = rootIndex \u003C 0\n\t\t\t? -1\n\t\t\t: sourceModelDoc.IndexOf( \u0022children\u0022, rootIndex, System.StringComparison.Ordinal );\n\t\tvar openingBracket = childrenIndex \u003C 0\n\t\t\t? -1\n\t\t\t: sourceModelDoc.IndexOf( \u0027[\u0027, childrenIndex );\n\t\tif ( openingBracket \u003C 0 )\n\t\t\tthrow new System.InvalidOperationException( \u0022The source VMDL does not expose a writable root child list.\u0022 );\n\n\t\tvar insertion = \u0022\\n\u0022 \u002B modifierList.Trim() \u002B \u0022\\n\u0022;\n\t\treturn sourceModelDoc.Insert( openingBracket \u002B 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\u003C(int Start, int End)\u003E();\n\t\tvar search = 0;\n\t\twhile ( true )\n\t\t{\n\t\t\tvar classIndex = sourceModelDoc.IndexOf(\n\t\t\t\t\u0022_class = \\\u0022RenderMeshFile\\\u0022\u0022,\n\t\t\t\tsearch,\n\t\t\t\tStringComparison.Ordinal );\n\t\t\tif ( classIndex \u003C 0 )\n\t\t\t\tbreak;\n\t\t\tvar opening = sourceModelDoc.LastIndexOf( \u0027{\u0027, classIndex );\n\t\t\tvar closing = opening \u003C 0 ? -1 : FindClosingBrace( sourceModelDoc, opening );\n\t\t\tif ( opening \u003C 0 || closing \u003C 0 )\n\t\t\t\tthrow new InvalidOperationException(\n\t\t\t\t\t\u0022The source VMDL contains a malformed RenderMeshFile node.\u0022 );\n\t\t\tblocks.Add( (opening, closing \u002B 1) );\n\t\t\tsearch = closing \u002B 1;\n\t\t}\n\n\t\tif ( blocks.Count == 0 )\n\t\t\tthrow new InvalidOperationException(\n\t\t\t\t\u0022The source VMDL does not contain an editable RenderMeshFile node.\u0022 );\n\n\t\tvar result = new StringBuilder( sourceModelDoc );\n\t\tforeach ( var (start, end) in blocks.OrderByDescending( block =\u003E block.Start ) )\n\t\t{\n\t\t\tvar block = sourceModelDoc[start..end];\n\t\t\tvar sourceAngles = ReadVector( block, \u0022import_rotation\u0022, Vector3.Zero );\n\t\t\tvar source = new Transform(\n\t\t\t\tReadVector( block, \u0022import_translation\u0022, Vector3.Zero ),\n\t\t\t\tRotation.From( sourceAngles.x, sourceAngles.y, sourceAngles.z ),\n\t\t\t\tnew Vector3( ReadScalar( block, \u0022import_scale\u0022, 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\u0022import_translation\u0022,\n\t\t\t\t$\u0022[ {F( combined.Position.x )}, {F( combined.Position.y )}, {F( combined.Position.z )} ]\u0022 );\n\t\t\tblock = ReplaceField(\n\t\t\t\tblock,\n\t\t\t\t\u0022import_rotation\u0022,\n\t\t\t\t$\u0022[ {F( angles.pitch )}, {F( angles.yaw )}, {F( angles.roll )} ]\u0022 );\n\t\t\tblock = ReplaceField( block, \u0022import_scale\u0022, 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 = $@\u0022(?m)^(\\s*){Regex.Escape( name )}\\s*=\\s*(\\[[^\\]]*\\]|[^\\r\\n]\u002B)\u0022;\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$\u0022${{1}}{name} = {value}\u0022,\n\t\t\t\t1 );\n\n\t\tvar classLine = block.IndexOf(\n\t\t\t\u0022_class = \\\u0022RenderMeshFile\\\u0022\u0022,\n\t\t\tStringComparison.Ordinal );\n\t\tvar lineEnd = classLine \u003C 0 ? -1 : block.IndexOf( \u0027\\n\u0027, classLine );\n\t\tif ( lineEnd \u003C 0 )\n\t\t\tthrow new InvalidOperationException(\n\t\t\t\t$\u0022The source RenderMeshFile cannot receive \u0027{name}\u0027.\u0022 );\n\t\tvar indentation = Regex.Match( block[(block.LastIndexOf( \u0027\\n\u0027, classLine ) \u002B 1)..], @\u0022^\\s*\u0022 ).Value;\n\t\treturn block.Insert( lineEnd \u002B 1, $\u0022{indentation}{name} = {value}\\n\u0022 );\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$@\u0022(?m)^\\s*{Regex.Escape( name )}\\s*=\\s*\\[\\s*({NumberPattern})\\s*,\\s*({NumberPattern})\\s*,\\s*({NumberPattern})\\s*\\]\u0022 );\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$@\u0022(?m)^\\s*{Regex.Escape( name )}\\s*=\\s*({NumberPattern})\u0022 );\n\t\treturn match.Success ? ParseNumber( match.Groups[1].Value ) : fallback;\n\t}\n\n\tprivate static float ParseNumber( string value ) =\u003E\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 \u003C text.Length; index\u002B\u002B )\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 == \u0027\\\\\u0027 )\n\t\t\t\t\tescaped = true;\n\t\t\t\telse if ( character == \u0027\u0022\u0027 )\n\t\t\t\t\tquoted = false;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( character == \u0027\u0022\u0027 )\n\t\t\t{\n\t\t\t\tquoted = true;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif ( character == \u0027/\u0027 \u0026\u0026 index \u002B 1 \u003C text.Length )\n\t\t\t{\n\t\t\t\tif ( text[index \u002B 1] == \u0027/\u0027 )\n\t\t\t\t{\n\t\t\t\t\tindex = text.IndexOf( \u0027\\n\u0027, index \u002B 2 );\n\t\t\t\t\tif ( index \u003C 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 \u002B 1] == \u0027*\u0027 )\n\t\t\t\t{\n\t\t\t\t\tindex = text.IndexOf( \u0022*/\u0022, index \u002B 2, StringComparison.Ordinal );\n\t\t\t\t\tif ( index \u003C 0 )\n\t\t\t\t\t\treturn -1;\n\t\t\t\t\tindex\u002B\u002B;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif ( character == \u0027{\u0027 )\n\t\t\t\tdepth\u002B\u002B;\n\t\t\telse if ( character == \u0027}\u0027 \u0026\u0026 --depth == 0 )\n\t\t\t\treturn index;\n\t\t}\n\t\treturn -1;\n\t}\n\n\tprivate const string NumberPattern = @\u0022[-\u002B]?(?:\\d\u002B(?:\\.\\d*)?|\\.\\d\u002B)(?:[eE][-\u002B]?\\d\u002B)?\u0022;\n\n\tprivate static string BuildSourceModifierList(\n\t\tstring sourceRootBoneName,\n\t\tSystem.Collections.Generic.IEnumerable\u003Cstring\u003E? excludedBranchRoots )\n\t{\n\t\tvar modifiers = new System.Collections.Generic.List\u003Cstring\u003E();\n\t\tif ( !string.IsNullOrWhiteSpace( sourceRootBoneName )\n\t\t\t\u0026\u0026 !sourceRootBoneName.Equals( \u0022weapon_root\u0022, System.StringComparison.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tmodifiers.Add( $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t_class = \u0022RenameBone\u0022\n\t\t\t\t\t\t\t\t\t\toriginal_bone_name = \u0022{{Escape( sourceRootBoneName )}}\u0022\n\t\t\t\t\t\t\t\t\t\tnew_bone_name = \u0022weapon_root\u0022\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\u0022\u0022\u0022 );\n\t\t}\n\n\t\tvar excluded = excludedBranchRoots?\n\t\t\t.Where( x =\u003E !string.IsNullOrWhiteSpace( x ) )\n\t\t\t.Distinct( System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( x =\u003E x, System.StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToArray() ?? [];\n\t\tif ( excluded.Length \u003E 0 )\n\t\t{\n\t\t\tvar boneNames = string.Join(\n\t\t\t\t\u0022\\n\u0022,\n\t\t\t\texcluded.Select( x =\u003E $\u0022\\t\\t\\t\\t\\t\\t\\t\\t\\t\\\u0022{Escape( x )}\\\u0022,\u0022 ) );\n\t\t\tmodifiers.Add( $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t_class = \u0022RemoveBoneAndChildren\u0022\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\u0022\u0022\u0022 );\n\t\t}\n\n\t\tvar modifierList = modifiers.Count == 0\n\t\t\t\t? \u0022\u0022\n\t\t\t\t: $$\u0022\u0022\u0022\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t_class = \u0022ModelModifierList\u0022\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( \u0022\\n\u0022, 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\u0022\u0022\u0022;\n\t\treturn modifierList;\n\t}\n\n\tprivate static string F( float value ) =\u003E\n\t\tvalue.ToString( \u00220.######\u0022, CultureInfo.InvariantCulture );\n\n\tprivate static string Escape( string value ) =\u003E\n\t\tvalue.Replace( \u0022\\\\\u0022, \u0022\\\\\\\\\u0022 ).Replace( \u0022\\\u0022\u0022, \u0022\\\\\\\u0022\u0022 );\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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/// \u003Csummary\u003E\n\t/// Samples the bone depth ramp. \u003Cparamref name=\u0022fraction\u0022/\u003E is 0 at the skeleton root and 1 at\n\t/// the deepest bone.\n\t/// \u003C/summary\u003E\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 \u002B 1],\n\t\t\tscaled - index );\n\t}\n\n\tpublic const string PanelStyle =\n\t\t\u0022background-color: rgb(21,23,26);\u0022 \u002B\n\t\t\u0022border: 1px solid rgba(255,255,255,0.075);\u0022 \u002B\n\t\t\u0022border-radius: 3px;\u0022;\n\n\tpublic const string InputStyle =\n\t\t\u0022background-color: rgb(13,15,17);\u0022 \u002B\n\t\t\u0022border: 1px solid rgba(255,255,255,0.09);\u0022 \u002B\n\t\t\u0022border-radius: 3px;\u0022 \u002B\n\t\t\u0022color: rgb(224,229,234);\u0022 \u002B\n\t\t\u0022selection-background-color: rgb(31,126,151);\u0022 \u002B\n\t\t\u0022padding: 0 7px;\u0022 \u002B\n\t\t\u0022font-size: 11px;\u0022;\n\n\tpublic static Sandbox.UI.Margin ScrollCanvasMargin( float padding = 0 ) =\u003E\n\t\tnew( padding, padding, padding \u002B 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\u0022background-color: transparent; border: none; padding: 0px;\u0022 \u002B\n\t\t\t$\u0022font-size: 11px; color: {(muted ? Muted : Text).Hex};\u0022 );\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\u0022background-color: transparent; border: none; padding: 0px;\u0022 \u002B\n\t\t\t$\u0022font-size: 9px; font-weight: 600; letter-spacing: 0.65px; color: {(color ?? Muted).Hex};\u0022 \u002B\n\t\t\t(topMargin ? \u0022margin-top: 7px;\u0022 : \u0022\u0022) );\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() =\u003E PreferredSize();\n\tprotected override Vector2 MinimumSizeHint() =\u003E PreferredSize();\n\tpublic float PreferredWidth =\u003E 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 \u002B content.IconWidth \u002B content.Gap \u002B 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 \u003E 0;\n\t\tvar gap = hasIcon \u0026\u0026 hasText ? spacing : 0;\n\t\tvar iconWidth = hasIcon ? iconSize : 0;\n\t\tvar contentWidth = textWidth \u002B iconWidth \u002B 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 \u0026\u0026 Paint.HasMouseOver )\n\t\t\t\tcolor = color with { Value = MathF.Min( color.Value \u002B 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 \u002B 0.1f };\n\t\t\telse if ( Paint.HasMouseOver )\n\t\t\t\tbackground = color with { Value = color.Value \u002B 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 \u0026\u0026 (!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 \u003E 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 \u002B 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 ?? \u0022\u0022;\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 \u002B= content.IconWidth \u002B content.Gap;\n\t\t}\n\n\t\tif ( measuredText.x \u003E 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\u0022background-color: rgb(27,30,34); border: none;\u0022 );\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\u0022background-color: transparent; border: none; padding: 0px;\u0022 \u002B\n\t\t\t$\u0022font-family: Material Icons; font-size: 15px; color: {WeaponAnimatorTheme.Cyan.Hex};\u0022 );\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\u0022background-color: transparent; border: none; padding: 0px;\u0022 \u002B\n\t\t\t$\u0022font-size: 10px; font-weight: 600; letter-spacing: 0.65px; color: {WeaponAnimatorTheme.Text.Hex};\u0022 );\n\t\theader.Layout.Add( TitleLabel );\n\t\theader.Layout.AddStretchCell();\n\n\t\tStatusLabel = WeaponAnimatorTheme.Label( \u0022\u0022, 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( \u0022background-color: rgba(255,255,255,0.07); border: none;\u0022 );\n\t\tLayout.Add( separator );\n\n\t\tBody = content ?? new Widget( this );\n\t\tBody.SetStyles( \u0022background-color: transparent; border: none;\u0022 );\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\u003CToolbarAction\u003E _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\u0022background-color: rgb(18,20,23);\u0022 \u002B\n\t\t\t\u0022border-bottom: 1px solid rgba(255,255,255,0.08);\u0022 );\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 ) =\u003E\n\t\tAddButton( _center, text, icon, clicked, primary );\n\n\tpublic Button AddRight( string text, string icon, System.Action clicked, bool primary = false ) =\u003E\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 =\u003E !x.OverflowAtNarrowWidth ) )\n\t\t\treturn;\n\n\t\t_overflowButton = (WeaponAnimatorButton)AddButton(\n\t\t\t_left,\n\t\t\t\u0022More\u0022,\n\t\t\t\u0022more_horiz\u0022,\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 \u003E 0 \u0026\u0026 availableWidth \u003C 1380;\n\t\tforeach ( var action in _leftActions.Where( x =\u003E x.OverflowAtNarrowWidth ) )\n\t\t\taction.Button.Visible = !narrow;\n\t\t_overflowButton.Visible = narrow;\n\t}\n\n\tinternal bool UsesOverflow =\u003E _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 =\u003E 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 ) =\u003E\n\t\tsection.Children\n\t\t\t.OfType\u003CWeaponAnimatorButton\u003E()\n\t\t\t.Where( x =\u003E x.Visible )\n\t\t\t.Sum( x =\u003E x.PreferredWidth \u002B 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( \u0022background-color: transparent; border: none;\u0022 );\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 = \u0022Weapon Animation Project\u0022,\n\tExtension = \u0022wepanim\u0022,\n\tCategory = \u0022Animation\u0022,\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; } = \u0022\u0022;\n\tpublic ValidationReport Validation { get; init; } = new();\n\tpublic List\u003CGenerationDiagnostic\u003E Diagnostics { get; init; } = [];\n\tpublic List\u003Cstring\u003E 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 = \u00222.1.0\u0022;\n\tprivate const string ManifestFile = \u0022weaponanim.manifest.json\u0022;\n\n\tpublic async Task\u003CGenerationResult\u003E GenerateAsync(\n\t\tWeaponAnimationDocument document,\n\t\tAction\u003CGenerationProgress\u003E? 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$\u0022[Weapon Animator] generation timing: {stage} took \u0022\n\t\t\t\t\u002B $\u0022{totalMilliseconds - previousStageMilliseconds} ms \u0022\n\t\t\t\t\u002B $\u0022({execution}, total {totalMilliseconds} ms).\u0022 );\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( $\u0022[Weapon Animator] could not snapshot the project for generation: {ex}\u0022 );\n\t\t\treturn Failed(\n\t\t\t\tnew ValidationReport(),\n\t\t\t\t\u0022generation.snapshot\u0022,\n\t\t\t\t$\u0022Could not snapshot the project for generation: {ex.Message}\u0022 );\n\t\t}\n\t\tLogStage( \u0022snapshot\u0022, \u0022editor thread\u0022 );\n\n\t\tprogress?.Invoke( new GenerationProgress( \u0022Validate\u0022, \u0022Validating the weapon project\u0022 ) );\n\t\tvar validation = WeaponAnimationValidator.ValidateForGeneration( generationDocument );\n\t\tif ( !validation.IsValid )\n\t\t\treturn Failed( validation, \u0022generation.validation\u0022, \u0022Generation is blocked by validation errors.\u0022 );\n\t\tLogStage( \u0022validation\u0022, \u0022editor thread\u0022 );\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( $\u0022[Weapon Animator] output path resolution failed: {ex}\u0022 );\n\t\t\treturn Failed(\n\t\t\t\tvalidation,\n\t\t\t\t\u0022generation.output\u0022,\n\t\t\t\t$\u0022Could not prepare the generated output folder: {ex.Message}\u0022 );\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( $\u0022[Weapon Animator] could not load the ownership manifest: {ex}\u0022 );\n\t\t\treturn Failed(\n\t\t\t\tvalidation,\n\t\t\t\t\u0022ownership.manifest\u0022,\n\t\t\t\t$\u0022Could not read the generated ownership manifest: {ex.Message}\u0022 );\n\t\t}\n\t\tLogStage( \u0022paths and ownership\u0022, \u0022editor thread\u0022 );\n\n\t\tvar diagnostics = new List\u003CGenerationDiagnostic\u003E();\n\t\tHostSkeleton skeleton;\n\t\tDictionary\u003Cstring, string\u003E files;\n\t\tIReadOnlyList\u003CWeaponMaterialPipeline.GeneratedTextureCopy\u003E textureCopies;\n\t\ttry\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tprogress?.Invoke( new GenerationProgress( \u0022Prepare\u0022, \u0022Building generated source files\u0022 ) );\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\u0022source assembly\u0022,\n\t\t\t\t\u0022sequence sampling on worker; final assembly on editor thread\u0022 );\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( $\u0022[Weapon Animator] could not assemble generated sources: {ex}\u0022 );\n\t\t\treturn Failed(\n\t\t\t\tvalidation,\n\t\t\t\t\u0022generation.sources\u0022,\n\t\t\t\t$\u0022Could not assemble generated sources: {ex.Message}\u0022 );\n\t\t}\n\t\tvar previousFiles = generationDocument.Manifest.Files\n\t\t\t.Select( x =\u003E x.RelativePath )\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\n\t\tvar generatedSourcePaths = files.Keys\n\t\t\t.Concat( textureCopies.Select( copy =\u003E 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 ) \u0026\u0026 !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\u0022ownership.conflict\u0022,\n\t\t\t\t\t$\u0022Refusing to replace unowned file \u0027{file}\u0027.\u0022,\n\t\t\t\t\tabsolute ) );\n\t\t\t}\n\t\t}\n\n\t\tif ( diagnostics.Any( x =\u003E 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( \u0022ownership conflict check\u0022, \u0022editor thread\u0022 );\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( \u0022abandoned-stage cleanup\u0022, \u0022editor thread\u0022 );\n\n\t\tvar backups = new Dictionary\u003Cstring, byte[]\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tvar newFiles = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase );\n\t\ttry\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tprogress?.Invoke( new GenerationProgress( \u0022Write\u0022, \u0022Writing persistent generation sources\u0022 ) );\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( \u0022consumer reset\u0022, \u0022editor thread\u0022 );\n\t\t\tawait RunResponsiveWorkerAsync( \u0022persistent source writes\u0022, () =\u003E\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( \u0022persistent source writes\u0022, \u0022worker\u0022 );\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\u0022Register\u0022,\n\t\t\t\t\u0022Registering generated dependencies\u0022,\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( \u0022dependency registration\u0022, \u0022editor thread with per-file yields\u0022 );\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( \u0022compile and inspection\u0022, \u0022asset system/resource compiler\u0022 );\n\t\t\tif ( diagnostics.Any( x =\u003E x.Severity == ValidationSeverity.Error ) )\n\t\t\t\tthrow new InvalidOperationException( \u0022One or more generated assets failed to compile or reload.\u0022 );\n\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\u0022Finalize\u0022,\n\t\t\t\t\u0022Removing obsolete owned files\u0022 ) );\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( \u0022obsolete output cleanup\u0022, \u0022editor thread\u0022 );\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\u0022Finalize\u0022,\n\t\t\t\t\u0022Hashing generated sources and writing the manifest\u0022 ) );\n\t\t\tvar manifest = await RunResponsiveWorkerAsync( \u0022manifest hashing\u0022, () =\u003E\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( \u0022manifest hashing and write\u0022, \u0022worker\u0022 );\n\t\t\tgenerationDocument.Manifest = manifest;\n\t\t\tdocument.Manifest = manifest;\n\t\t\tprogress?.Invoke( new GenerationProgress( \u0022Complete\u0022, \u0022Generation completed\u0022 ) );\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 =\u003E 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( \u0022[Weapon Animator] generation cancelled; owned outputs were restored.\u0022 );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Warning,\n\t\t\t\t\u0022generation.cancelled\u0022,\n\t\t\t\t\u0022Generation was cancelled and the previous owned outputs were restored.\u0022 ) );\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( $\u0022[Weapon Animator] generation rolled back: {ex}\u0022 );\n\t\t\tRestoreGeneratedFiles( newFiles, backups );\n\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\u0022generation.rollback\u0022,\n\t\t\t\t$\u0022Generation failed and owned outputs were restored: {ex.Message}\u0022 ) );\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\u003CWeaponMaterialPipeline.GeneratedTextureCopy\u003E copies,\n\t\tDictionary\u003Cstring, byte[]\u003E backups,\n\t\tHashSet\u003Cstring\u003E newFiles,\n\t\tIReadOnlySet\u003Cstring\u003E previouslyOwnedFiles,\n\t\tCancellationToken cancellationToken = default )\n\t{\n\t\tforeach ( var copy in copies.OrderBy(\n\t\t\tcopy =\u003E 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$\u0022Texture source for \u0027{copy.RelativePath}\u0027 no longer exists.\u0022,\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\u003Cstring, string\u003E files,\n\t\tDictionary\u003Cstring, byte[]\u003E? backups = null,\n\t\tHashSet\u003Cstring\u003E? newFiles = null,\n\t\tIReadOnlySet\u003Cstring\u003E? 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 \u0026\u0026 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\u0026\u0026 !File.Exists( absolute )\n\t\t\t\t\u0026\u0026 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\u0027s\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 \u0022file stopped existing\u0022 \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\u003Cstring, string\u003E files ) =\u003E\n\t\tWriteFiles( root, files );\n\n\tinternal static IEnumerable\u003Cstring\u003E OrderForWrite( IEnumerable\u003Cstring\u003E paths ) =\u003E\n\t\tpaths\n\t\t\t.OrderBy( ConsumerWriteRank )\n\t\t\t.ThenBy( path =\u003E 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\u0022.vtex\u0022 =\u003E 1,\n\t\t\t\u0022.vmat\u0022 =\u003E 2,\n\t\t\t\u0022.vanmgrph\u0022 =\u003E 5,\n\t\t\t\u0022.vmdl\u0022 =\u003E 6,\n\t\t\t\u0022.prefab\u0022 =\u003E 7,\n\t\t\t_ =\u003E 8\n\t\t};\n\t}\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tprivate static readonly string[] CompiledConsumerExtensions =\n\t\t[\u0022.vtex\u0022, \u0022.vmat\u0022, \u0022.vmdl\u0022, \u0022.vanmgrph\u0022, \u0022.prefab\u0022];\n\n\tprivate static bool IsCompiledConsumer( string path ) =\u003E\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 ) =\u003E\n\t\t!previouslyOwned\n\t\t|| IsCompiledConsumer( relativePath );\n\n\tprivate static int ConsumerRemovalRank( string path ) =\u003E\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\u0022.prefab\u0022 =\u003E 7,\n\t\t\t\t\u0022.vmdl\u0022 =\u003E 6,\n\t\t\t\t\u0022.vanmgrph\u0022 =\u003E 5,\n\t\t\t\t\u0022.vmat\u0022 =\u003E 2,\n\t\t\t\t\u0022.vtex\u0022 =\u003E 1,\n\t\t\t\t_ =\u003E 0\n\t\t\t};\n\n\tprivate static bool IsSourceAdapter( string path ) =\u003E\n\t\tpath.EndsWith( \u0022_source_adapter.vmdl\u0022, StringComparison.OrdinalIgnoreCase );\n\n\tprivate static bool IsBootstrapHost( string path ) =\u003E\n\t\tpath.EndsWith( \u0022_host_bootstrap.vmdl\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t|| path.EndsWith( \u0022_vm_bootstrap.vmdl\u0022, StringComparison.OrdinalIgnoreCase );\n\n\tinternal static IEnumerable\u003Cstring\u003E OrderForRemoval( IEnumerable\u003Cstring\u003E absolutePaths ) =\u003E\n\t\tabsolutePaths\n\t\t\t.OrderByDescending( ConsumerRemovalRank )\n\t\t\t.ThenBy( path =\u003E path, StringComparer.OrdinalIgnoreCase );\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tprivate static void PrepareCompiledConsumersForRewrite(\n\t\tstring outputRoot,\n\t\tIEnumerable\u003Cstring\u003E relativePaths,\n\t\tDictionary\u003Cstring, byte[]\u003E backups,\n\t\tHashSet\u003Cstring\u003E newFiles )\n\t{\n\t\tvar consumers = relativePaths\n\t\t\t.Where( IsCompiledConsumer )\n\t\t\t.Select( path =\u003E 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$\u0022[Weapon Animator] resetting registered consumer \u0027{registered.Path}\u0027 \u0022\n\t\t\t\t\t\u002B \u0022before generation.\u0022 );\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 \u002B \u0022_c\u0022 } )\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\u003Cstring\u003E relativePaths )\n\t{\n\t\tvar missing = relativePaths\n\t\t\t.Where( path =\u003E !File.Exists( Path.Combine( outputRoot, path ) ) )\n\t\t\t.ToArray();\n\t\tif ( missing.Length \u003E 0 )\n\t\t{\n\t\t\tthrow new IOException(\n\t\t\t\t$\u0022Generated source set is incomplete: {string.Join( \u0022, \u0022, missing )}.\u0022 );\n\t\t}\n\n\t\tLog.Info(\n\t\t\t$\u0022[Weapon Animator] verified {relativePaths.Count()} persistent generation sources \u0022\n\t\t\t\u002B $\u0022under \u0027{outputRoot}\u0027.\u0022 );\n\t}\n\n\tprivate static async Task RegisterGeneratedDependenciesAsync(\n\t\tstring outputRoot,\n\t\tIEnumerable\u003Cstring\u003E relativePaths,\n\t\tAction\u003CGenerationProgress\u003E? progress,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar sources = relativePaths\n\t\t\t.Where( path =\u003E !IsCompiledConsumer( path ) )\n\t\t\t.Select( path =\u003E Path.Combine( outputRoot, path ) )\n\t\t\t.ToArray();\n\t\tfor ( var index = 0; index \u003C sources.Length; index\u002B\u002B )\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\u0022Register\u0022,\n\t\t\t\tPath.GetFileName( absolute ),\n\t\t\t\tindex \u002B 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$\u0022[Weapon Animator] dependency registration \u0027{Path.GetFileName( absolute )}\u0027 \u0022\n\t\t\t\t\u002B $\u0022took {timer.ElapsedMilliseconds} ms \u0022\n\t\t\t\t\u002B $\u0022({new FileInfo( absolute ).Length} bytes).\u0022 );\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$\u0022The asset system did not retain generated dependency \u0027{absolute}\u0027.\u0022 );\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$\u0022[Weapon Animator] registered {sources.Length} persistent source dependencies \u0022\n\t\t\t\u002B \u0022before compiling their consumers.\u0022 );\n\t}\n\n\tinternal static void DeleteGeneratedFiles( IEnumerable\u003Cstring\u003E 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$\u0022[Weapon Animator] unregistering generated asset \u0027{asset.Path}\u0027 \u0022\n\t\t\t\t\t\t\u002B $\u0022before removing \u0027{path}\u0027.\u0022 );\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$\u0022[Weapon Animator] asset-aware removal failed for \u0027{path}\u0027: {ex.Message}\u0022 );\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 \u002B \u0022_c\u0022 } )\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( $\u0022[Weapon Animator] could not remove \u0027{target}\u0027: {ex.Message}\u0022 );\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( deletedByAssetSystem )\n\t\t\t\tLog.Info( $\u0022[Weapon Animator] asset registry removal completed for \u0027{path}\u0027.\u0022 );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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\u0022generation-stage\u0022 );\n\t\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\t\tvar stalePaths = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase )\n\t\t\t{\n\t\t\t\tPath.Combine( stageRoot, $\u0022{slug}_host.vmdl\u0022 ),\n\t\t\t\tPath.Combine( stageRoot, $\u0022{slug}_vm.vmdl\u0022 ),\n\t\t\t\tPath.Combine( stageRoot, $\u0022{slug}_host_bootstrap.vmdl\u0022 ),\n\t\t\t\tPath.Combine( stageRoot, $\u0022{slug}_vm_bootstrap.vmdl\u0022 ),\n\t\t\t\tPath.Combine( stageRoot, $\u0022{slug}_source.vmdl\u0022 ),\n\t\t\t\tPath.Combine( stageRoot, $\u0022{slug}.vanmgrph\u0022 ),\n\t\t\t\tPath.Combine( stageRoot, $\u0022v_{slug}.prefab\u0022 ),\n\t\t\t\tPath.Combine( stageRoot, $\u0022{slug}_host_reference.dmx\u0022 )\n\t\t\t};\n\t\t\tforeach ( var clip in document.Clips )\n\t\t\t{\n\t\t\t\tvar stem = $\u0022{slug}_{WeaponAnimationNames.SequenceName( clip )}\u0022;\n\t\t\t\tstalePaths.Add( Path.Combine( stageRoot, $\u0022{stem}.smd\u0022 ) );\n\t\t\t\tstalePaths.Add( Path.Combine( stageRoot, $\u0022{stem}.dmx\u0022 ) );\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\u002B Path.DirectorySeparatorChar;\n\t\t\tvar registeredStagePaths = AssetSystem.All\n\t\t\t\t.Where( asset =\u003E !string.IsNullOrWhiteSpace( asset.AbsolutePath ) )\n\t\t\t\t.Select( asset =\u003E Path.GetFullPath( asset.AbsolutePath ) )\n\t\t\t\t.Where( path =\u003E 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\u0022*\u0022,\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 \u003E 0 )\n\t\t\t\tLog.Info( $\u0022[Weapon Animator] removed abandoned generation stage \u0027{stageRoot}\u0027.\u0022 );\n\t\t}\n\t\tcatch ( Exception ex )\n\t\t{\n\t\t\tLog.Warning( $\u0022[Weapon Animator] could not clear the abandoned generation stage: {ex.Message}\u0022 );\n\t\t}\n\t}\n\n\tprivate static void RemoveObsoleteOwnedFiles(\n\t\tWeaponAnimationDocument document,\n\t\tstring outputRoot,\n\t\tIEnumerable\u003Cstring\u003E generatedFiles,\n\t\tList\u003CGenerationDiagnostic\u003E diagnostics )\n\t{\n\t\tvar retained = generatedFiles.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tvar obsolete = document.Manifest.Files\n\t\t\t.Select( x =\u003E x.RelativePath )\n\t\t\t.Where( x =\u003E !retained.Contains( x ) )\n\t\t\t.Select( x =\u003E Path.Combine(\n\t\t\t\toutputRoot,\n\t\t\t\tx.Replace( \u0027/\u0027, 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\u0022ownership.obsolete_removed\u0022,\n\t\t\t\t$\u0022Removed obsolete generated file \u0027{Path.GetFileName( path )}\u0027.\u0022,\n\t\t\t\tpath ) );\n\t\t}\n\t}\n\n\tpublic static string GetOutputFolder( WeaponAnimationDocument document ) =\u003E\n\t\tResolveOutputRoot( document );\n\n\tinternal static Dictionary\u003Cstring, string\u003E BuildFiles(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tstring relativeRoot,\n\t\tAction\u003CGenerationProgress\u003E? 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\u003CDictionary\u003Cstring, string\u003E\u003E BuildFilesResponsiveAsync(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tstring relativeRoot,\n\t\tAction\u003CGenerationProgress\u003E? progress,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar orderedClips = GeneratedClips( document )\n\t\t\t.OrderBy( clip =\u003E clip.Name )\n\t\t\t.ToArray();\n\t\tvar preparedClips = new List\u003CPreparedClipSource\u003E( orderedClips.Length );\n\t\tfor ( var clipIndex = 0; clipIndex \u003C orderedClips.Length; clipIndex\u002B\u002B )\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\u0022Sequences\u0022,\n\t\t\t\t$\u0022Sampling {clip.Name}\u0022,\n\t\t\t\tclipIndex \u002B 1,\n\t\t\t\torderedClips.Length ) );\n\t\t\tvar source = await RunResponsiveWorkerAsync( $\u0022sequence {clip.Name}\u0022, () =\u003E\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\u003CPreparedClipSource\u003E BuildClipSources(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tAction\u003CGenerationProgress\u003E? progress,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar orderedClips = GeneratedClips( document )\n\t\t\t.OrderBy( clip =\u003E clip.Name )\n\t\t\t.ToArray();\n\t\tvar preparedClips = new List\u003CPreparedClipSource\u003E( orderedClips.Length );\n\t\tfor ( var clipIndex = 0; clipIndex \u003C orderedClips.Length; clipIndex\u002B\u002B )\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\u0022Sequences\u0022,\n\t\t\t\t$\u0022Sampling {clip.Name}\u0022,\n\t\t\t\tclipIndex \u002B 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\u003Cstring, string\u003E AssembleFiles(\n\t\tWeaponAnimationDocument document,\n\t\tHostSkeleton skeleton,\n\t\tstring relativeRoot,\n\t\tIReadOnlyList\u003CPreparedClipSource\u003E preparedClips )\n\t{\n\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\tvar files = new Dictionary\u003Cstring, string\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tvar referenceName = $\u0022{slug}_host_reference.dmx\u0022;\n\t\tvar bootstrapHostName = $\u0022{slug}_vm_bootstrap.vmdl\u0022;\n\t\tvar hostName = $\u0022{slug}_vm.vmdl\u0022;\n\t\tvar graphName = $\u0022{slug}.vanmgrph\u0022;\n\t\tvar prefabName = $\u0022v_{slug}.prefab\u0022;\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\u003C(WeaponAnimationClip Clip, string Source)\u003E();\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$\u0022{slug}_sequence_{WeaponAnimationNames.SequenceName( clip )}.dmx\u0022;\n\t\t\tfiles[clipName] = preparedClip.Source;\n\t\t\tclipSources.Add( (clip, $\u0022{relativeRoot}/{clipName}\u0022) );\n\t\t}\n\n\t\tvar graphPath = document.Output.GenerateGraph \u0026\u0026 document.Graph.GenerateGraph\n\t\t\t? $\u0022{relativeRoot}/{graphName}\u0022\n\t\t\t: \u0022\u0022;\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 = \u0022\u0022;\n\t\tif ( IsVmdlSource( document ) )\n\t\t{\n\t\t\tvar adapterName = $\u0022{slug}_source_adapter.vmdl\u0022;\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\u0022The imported VMDL source no longer exists.\u0022,\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 = $\u0022{relativeRoot}/{adapterName}\u0022;\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$\u0022{relativeRoot}/{referenceName}\u0022,\n\t\t\tclipSources,\n\t\t\tgraphPath,\n\t\t\tskeleton.Bones.Select( bone =\u003E 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 \u0026\u0026 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 -\u003E graph -\u003E preview-host compile dependency.\n\t\t\tfiles[bootstrapHostName] = ModelDocWriter.WriteHost(\n\t\t\t\t$\u0022{relativeRoot}/{referenceName}\u0022,\n\t\t\t\tclipSources,\n\t\t\t\t\u0022\u0022,\n\t\t\t\tskeleton.Bones.Select( bone =\u003E 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$\u0022{relativeRoot}/{bootstrapHostName}\u0022 );\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$\u0022{relativeRoot}/{hostName}\u0022 );\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\u003CT\u003E RunResponsiveWorkerAsync\u003CT\u003E(\n\t\tstring stage,\n\t\tFunc\u003CT\u003E 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 \u003C nextHeartbeat )\n\t\t\t\tcontinue;\n\n\t\t\tLog.Info(\n\t\t\t\t$\u0022[Weapon Animator] worker heartbeat: \u0027{stage}\u0027 is still running after \u0022\n\t\t\t\t\u002B $\u0022{timer.ElapsedMilliseconds} ms; editor thread \u0022\n\t\t\t\t\u002B $\u0022{Environment.CurrentManagedThreadId} is pumping.\u0022 );\n\t\t\tnextHeartbeat \u002B= 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\u0026\u0026 !extension.Equals( \u0022.vmdl\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn document.Source.SourcePath;\n\n\t\tthrow new InvalidOperationException(\n\t\t\t$\u0022The standard single-renderer viewmodel currently needs an FBX, DMX, OBJ, or VMDL render source; \u0022\n\t\t\t\u002B $\u0022\u0027{extension}\u0027 cannot be embedded by ModelDoc.\u0022 );\n\t}\n\n\tinternal static IReadOnlyList\u003CWeaponAnimationClip\u003E GeneratedClips(\n\t\tWeaponAnimationDocument document ) =\u003E\n\t\tdocument.Clips\n\t\t\t.Where( clip =\u003E clip.Readiness != ClipReadiness.NotStarted )\n\t\t\t.ToArray();\n\n\tprivate static bool IsVmdlSource( WeaponAnimationDocument document ) =\u003E\n\t\tPath.GetExtension( document.Source.SourcePath )\n\t\t\t.Equals( \u0022.vmdl\u0022, StringComparison.OrdinalIgnoreCase );\n\n\tprivate static string ResolveSourceAbsolutePath( string sourcePath ) =\u003E\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( \u0027/\u0027, \u0027\\\\\u0027 ) ) );\n\n\tprivate static IEnumerable\u003CHostAttachment\u003E 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 =\u003E\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? \u0022weapon_root\u0022\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\u003Cstring\u003E ExcludedBranchRoots(\n\t\tWeaponAnimationDocument document )\n\t{\n\t\tforeach ( var bone in document.Rig.Bones.Where( x =\u003E\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/// \u003Csummary\u003E\n\t/// Seconds to let a single generated asset finish compiling before treating it as failed.\n\t/// \u003C/summary\u003E\n\tprivate const float CompileTimeoutSeconds = 120.0f;\n\tprivate const float HostReloadTimeoutSeconds = 20.0f;\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tinternal static async Task\u003Cbool\u003E 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$\u0022[Weapon Animator] compile request for \u0027{asset.Path}\u0027: queued={queued}, \u0022\n\t\t\t\u002B $\u0022deleted={asset.IsDeleted}, canRecompile={asset.CanRecompile}, \u0022\n\t\t\t\u002B $\u0022hasSource={asset.HasSourceFile}.\u0022 );\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$\u0022[Weapon Animator] accepted fresh compiled artifact \u0027{compiledAbsolute}\u0027 \u0022\n\t\t\t\t\t\t\u002B $\u0022while the managed asset flags for \u0027{current.Path}\u0027 caught up.\u0022 );\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 \u0026\u0026 current.HasCompiledFile )\n\t\t\t{\n\t\t\t\tLog.Error(\n\t\t\t\t\t$\u0022[Weapon Animator] \u0027{current.Path}\u0027 reports compiled but no fresh artifact \u0022\n\t\t\t\t\t\u002B $\u0022exists for \u0027{sourceAbsolute}\u0027.\u0022 );\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t\tif ( !retriedLiveAsset\n\t\t\t\t\u0026\u0026 !ReferenceEquals( current, asset )\n\t\t\t\t\u0026\u0026 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$\u0022[Weapon Animator] retried compile through the live asset entry \u0022\n\t\t\t\t\t\u002B $\u0022\u0027{current.Path}\u0027: queued={liveQueued}.\u0022 );\n\t\t\t}\n\t\t\tif ( DateTime.UtcNow \u003E deadline )\n\t\t\t{\n\t\t\t\tLog.Warning(\n\t\t\t\t\t$\u0022[Weapon Animator] \u0027{current.Path}\u0027 did not finish compiling within \u0022\n\t\t\t\t\t\u002B $\u0022{CompileTimeoutSeconds:0} seconds.\u0022 );\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tif ( DateTime.UtcNow \u003E= nextProgressLog )\n\t\t\t{\n\t\t\t\tLog.Info(\n\t\t\t\t\t$\u0022[Weapon Animator] waiting for \u0027{current.Path}\u0027: \u0022\n\t\t\t\t\t\u002B $\u0022compiled={current.IsCompiled}, upToDate={current.IsCompiledAndUpToDate}, \u0022\n\t\t\t\t\t\u002B $\u0022hasCompiledFile={current.HasCompiledFile}, deleted={current.IsDeleted}, \u0022\n\t\t\t\t\t\u002B $\u0022canRecompile={current.CanRecompile}, hasSource={current.HasSourceFile}, \u0022\n\t\t\t\t\t\u002B $\u0022sourceExists={File.Exists( sourceAbsolute )}.\u0022 );\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\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\tsourceAbsolute \u002B \u0022_c\u0022\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 =\u003E\n\t\t\tIsFreshCompiledArtifact( sourceAbsolute, compiled ) ) ?? \u0022\u0022;\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\u003E= 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\u003CGenerationDiagnostic\u003E diagnostics,\n\t\tAction\u003CGenerationProgress\u003E? progress,\n\t\tCancellationToken cancellationToken )\n\t{\n\t\tvar slug = WeaponAnimationDocument.Slugify( document.Output.AssetName );\n\t\tvar bootstrapHostFile = $\u0022{slug}_vm_bootstrap.vmdl\u0022;\n\t\tvar hostFile = $\u0022{slug}_vm.vmdl\u0022;\n\t\tvar graphEnabled = document.Output.GenerateGraph \u0026\u0026 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 =\u003E\n\t\t\tPath.GetExtension( path ).Equals( \u0022.vmat\u0022, StringComparison.OrdinalIgnoreCase ) );\n\t\tvar compileTotal = materialCount\n\t\t\t\u002B (IsVmdlSource( document ) ? 1 : 0)\n\t\t\t\u002B (graphEnabled ? 3 : 1)\n\t\t\t\u002B (document.Output.GeneratePrefab ? 1 : 0);\n\t\tvar compileIndex = 0;\n\n\t\tasync Task\u003Cbool\u003E 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\u002B\u002B;\n\t\t\tprogress?.Invoke( new GenerationProgress(\n\t\t\t\t\u0022Compile\u0022,\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$\u0022[Weapon Animator] consumer registration \u0027{description ?? file}\u0027 took \u0022\n\t\t\t\t\u002B $\u0022{registrationTimer.ElapsedMilliseconds} ms.\u0022 );\n\t\t\tif ( asset is null )\n\t\t\t{\n\t\t\t\tLog.Error( $\u0022[Weapon Animator] could not register \u0027{absolute}\u0027 as an asset.\u0022 );\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\t\u0022compile.failed\u0022,\n\t\t\t\t\t$\u0022Could not register \u0027{file}\u0027 with the asset system.\u0022,\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$\u0022[Weapon Animator] generation timing: compiled \u0022\n\t\t\t\t\t\u002B $\u0022\u0027{description ?? file}\u0027 in {timer.ElapsedMilliseconds} ms.\u0022 );\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Info,\n\t\t\t\t\t\u0022compile.ok\u0022,\n\t\t\t\t\t$\u0022Compiled \u0027{description ?? file}\u0027.\u0022,\n\t\t\t\t\tasset.Path ) );\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tLog.Error( $\u0022[Weapon Animator] failed to compile \u0027{absolute}\u0027.\u0022 );\n\t\t\tLog.Error(\n\t\t\t\t$\u0022[Weapon Animator] generation timing: failed \u0027{description ?? file}\u0027 \u0022\n\t\t\t\t\u002B $\u0022after {timer.ElapsedMilliseconds} ms.\u0022 );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\u0022compile.failed\u0022,\n\t\t\t\t$\u0022Failed to compile \u0027{description ?? file}\u0027.\u0022,\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 =\u003E Path.GetExtension( path ).Equals(\n\t\t\t\t\u0022.vmat\u0022,\n\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t.OrderBy( path =\u003E path, StringComparer.OrdinalIgnoreCase ) )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tif ( !await Compile( materialFile, $\u0022{materialFile} material\u0022 ) )\n\t\t\t\treturn;\n\t\t}\n\n\t\tif ( IsVmdlSource( document )\n\t\t\t\u0026\u0026 !await Compile(\n\t\t\t\t$\u0022{slug}_source_adapter.vmdl\u0022,\n\t\t\t\t$\u0022{slug}_source_adapter.vmdl source adapter\u0022 ) )\n\t\t\treturn;\n\n\t\tvar hostCompiled = false;\n\t\tif ( graphEnabled )\n\t\t{\n\t\t\tvar graphPath = $\u0022{relativeRoot}/{slug}.vanmgrph\u0022;\n\t\t\tvar linkedGraph = $\u0022anim_graph_name = \\\u0022{graphPath}\\\u0022\u0022;\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\u0022compile.graph_link\u0022,\n\t\t\t\t\t\u0022The final host source does not contain its generated AnimGraph link.\u0022,\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$\u0022{bootstrapHostFile} graph preview\u0022 );\n\t\t\tvar graphCompiled = await Compile( $\u0022{slug}.vanmgrph\u0022 );\n\t\t\tif ( bootstrapCompiled \u0026\u0026 graphCompiled )\n\t\t\t\thostCompiled = await Compile( hostFile, $\u0022{hostFile} with AnimGraph\u0022 );\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 = $\u0022{relativeRoot}/{slug}_vm.vmdl\u0022;\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 =\u003E bone.Name ).ToArray()\n\t\t\t: MissingRequiredBones( skeleton, host );\n\t\tif ( host is null || host.IsError || missingRequiredBones.Length \u003E 0 )\n\t\t{\n\t\t\tLog.Error(\n\t\t\t\t$\u0022[Weapon Animator] host \u0027{hostPath}\u0027 reloaded with {host?.BoneCount ?? 0} bones \u0022\n\t\t\t\t\u002B $\u0022(required {skeleton.Bones.Count}, missing {missingRequiredBones.Length}, \u0022\n\t\t\t\t\u002B $\u0022error: {host?.IsError.ToString() ?? \u0022not loaded\u0022}).\u0022 );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\u0022inspect.host\u0022,\n\t\t\t\t$\u0022Host reload is missing required bones: \u0022\n\t\t\t\t\u002B $\u0022{string.Join( \u0022, \u0022, missingRequiredBones.Take( 8 ) )}.\u0022,\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 \u003E 0 )\n\t\t{\n\t\t\tLog.Info(\n\t\t\t\t$\u0022[Weapon Animator] compiled host includes {additionalBones.Length} additional \u0022\n\t\t\t\t\u002B $\u0022source-mesh bone entries: {string.Join( \u0022, \u0022, additionalBones.Take( 16 ) )}.\u0022 );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Warning,\n\t\t\t\t\u0022inspect.additional_bones\u0022,\n\t\t\t\t$\u0022The visible source mesh contributed {additionalBones.Length} additional compiled \u0022\n\t\t\t\t\u002B \u0022bone entries; all required animation-host bones are present.\u0022,\n\t\t\t\thostPath ) );\n\t\t}\n\n\t\tvar normalizedScaleBones = CountCompiledScaleNormalizations( skeleton, host );\n\t\tif ( normalizedScaleBones \u003E 0 )\n\t\t{\n\t\t\tLog.Info(\n\t\t\t\t$\u0022[Weapon Animator] compiler normalized bind scale on {normalizedScaleBones} \u0022\n\t\t\t\t\u002B \u0022bone(s); calibrated mesh scale remains baked into the generated model.\u0022 );\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Info,\n\t\t\t\t\u0022inspect.bind_scale_normalized\u0022,\n\t\t\t\t$\u0022ModelDoc normalized bind scale on {normalizedScaleBones} bone(s).\u0022,\n\t\t\t\thostPath ) );\n\t\t}\n\n\t\tvar bindIssues = InspectCompiledBindPose( skeleton, host );\n\t\tif ( bindIssues.Count \u003E 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$\u0022[Weapon Animator] compiled bind mismatch \u0027{issue.BoneName}\u0027: \u0022\n\t\t\t\t\t\u002B $\u0022expectedParent=\u0027{expectedBone.ParentName}\u0027, \u0022\n\t\t\t\t\t\u002B $\u0022actualParent=\u0027{actualBone?.Parent?.Name ?? \u0022\u0022}\u0027, \u0022\n\t\t\t\t\t\u002B $\u0022position={issue.PositionDelta:0.######}, \u0022\n\t\t\t\t\t\u002B $\u0022rotation={issue.RotationDelta:0.######}, \u0022\n\t\t\t\t\t\u002B $\u0022scale={issue.ScaleDelta:0.######}, \u0022\n\t\t\t\t\t\u002B $\u0022expected={DescribeTransform( issue.Expected )}, \u0022\n\t\t\t\t\t\u002B $\u0022actual={DescribeTransform( issue.Actual )}.\u0022 );\n\t\t\t}\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\u0022inspect.bind_pose\u0022,\n\t\t\t\t$\u0022Compiled host bind pose differs from the authored host on {bindIssues.Count} \u0022\n\t\t\t\t\u002B $\u0022bone(s); first mismatch: {bindIssues[0].BoneName}.\u0022,\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 =\u003E !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 =\u003E !sequenceNames.Contains( name ) )\n\t\t\t.ToArray();\n\t\tLog.Info(\n\t\t\t$\u0022[Weapon Animator] host \u0027{hostPath}\u0027 exposes {host.AnimationCount} animation \u0022\n\t\t\t\u002B $\u0022sequence(s): {string.Join( \u0022, \u0022, sequenceNames.OrderBy( x =\u003E x ) )}.\u0022 );\n\t\tif ( missingSequences.Length \u003E 0 )\n\t\t{\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\u0022inspect.sequences\u0022,\n\t\t\t\t$\u0022Compiled host is missing generated animation sequences: \u0022\n\t\t\t\t\u002B $\u0022{string.Join( \u0022, \u0022, missingSequences )}.\u0022,\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\u0022inspect.sequences\u0022,\n\t\t\t$\u0022Host exposes all {expectedSequences.Length} generated animation sequences.\u0022,\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[] { \u0022b_attack\u0022, \u0022b_reload\u0022, \u0022b_empty\u0022 };\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 =\u003E !graph.TryGetParameterIndex( name, out _ ) )\n\t\t\t\t\t.ToArray();\n\t\t\tif ( graph is null || graph.IsError || missing.Length \u003E 0 )\n\t\t\t{\n\t\t\t\tLog.Error(\n\t\t\t\t\t$\u0022[Weapon Animator] host \u0027{hostPath}\u0027 has no usable AnimGraph parameters. \u0022\n\t\t\t\t\t\u002B $\u0022Graph loaded: {graph is not null}, graph error: {graph?.IsError.ToString() ?? \u0022n/a\u0022}, \u0022\n\t\t\t\t\t\u002B $\u0022missing: {string.Join( \u0022, \u0022, missing )}.\u0022 );\n\t\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\t\tValidationSeverity.Error,\n\t\t\t\t\t\u0022inspect.animgraph\u0022,\n\t\t\t\t\t$\u0022Host reload is missing AnimGraph parameters: {string.Join( \u0022, \u0022, missing )}.\u0022,\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\u0022inspect.animgraph\u0022,\n\t\t\t\t\t$\u0022Host exposes {graph.ParamCount} AnimGraph parameters, including the Facepunch firearm profile.\u0022,\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( $\u0022v_{slug}.prefab\u0022 );\n\t\t}\n\t}\n\n\tprivate static async Task\u003CModel?\u003E 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 \u003C= 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\u003CModel\u003E() ?? 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$\u0022[Weapon Animator] host reload attempt for \u0027{hostPath}\u0027 threw: {ex.Message}\u0022 );\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 \u0026\u0026 !last.IsError \u0026\u0026 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$\u0022[Weapon Animator] host \u0027{hostPath}\u0027 reloaded with \u0022\n\t\t\t\t\t\u002B $\u0022{last.BoneCount} bones after {elapsed:0} ms.\u0022 );\n\t\t\t\treturn last;\n\t\t\t}\n\n\t\t\tif ( DateTime.UtcNow \u003E= nextProgressLog )\n\t\t\t{\n\t\t\t\tLog.Info(\n\t\t\t\t\t$\u0022[Weapon Animator] waiting for host resource \u0027{hostPath}\u0027: \u0022\n\t\t\t\t\t\u002B $\u0022bones={last?.BoneCount ?? 0} (required {expectedBoneCount}, missing {missing}), \u0022\n\t\t\t\t\t\u002B $\u0022error={last?.IsError.ToString() ?? \u0022not loaded\u0022}, \u0022\n\t\t\t\t\t\u002B $\u0022assetPresent={asset is not null}, \u0022\n\t\t\t\t\t\u002B $\u0022compiledArtifact={File.Exists( hostAbsolute \u002B \u0022_c\u0022 )}.\u0022 );\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 ) =\u003E\n\t\tskeleton.Bones\n\t\t\t.Where( expected =\u003E host.Bones.GetBone( expected.Name ) is null )\n\t\t\t.Select( expected =\u003E 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 =\u003E bone.Name )\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tvar additionalNames = host.Bones.AllBones\n\t\t\t.Where( bone =\u003E !expected.Contains( bone.Name ) )\n\t\t\t.Select( bone =\u003E bone.Name );\n\t\tvar duplicateNames = host.Bones.AllBones\n\t\t\t.GroupBy( bone =\u003E bone.Name, StringComparer.OrdinalIgnoreCase )\n\t\t\t.Where( group =\u003E group.Count() \u003E 1 )\n\t\t\t.Select( group =\u003E $\u0022{group.Key} \u00D7{group.Count()}\u0022 );\n\t\treturn additionalNames\n\t\t\t.Concat( duplicateNames )\n\t\t\t.OrderBy( name =\u003E 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\u003CCompiledBindIssue\u003E 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\u003CCompiledBindIssue\u003E();\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 \u003E positionTolerance\n\t\t\t\t|| rotationDelta \u003E 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 ) =\u003E\n\t\tskeleton.Bones.Count( expected =\u003E\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\u0026\u0026 (expected.BindModelTransform.Scale - actual.LocalTransform.Scale).Length\n\t\t\t\t\t\u003E 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\u003CGenerationDiagnostic\u003E 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 =\u003E clip.Tracks )\n\t\t\t.Where( track =\u003E skeleton.ByName.TryGetValue( track.Target, out var bone )\n\t\t\t\t\u0026\u0026 bone.IsWeaponBone\n\t\t\t\t\u0026\u0026 track.Keys.Any( key =\u003E RotationDiffers(\n\t\t\t\t\tkey.Rotation,\n\t\t\t\t\tskeleton.GetBindLocal( bone ).Rotation ) ) )\n\t\t\t.Select( track =\u003E track.Target )\n\t\t\t.Distinct( StringComparer.OrdinalIgnoreCase )\n\t\t\t.OrderBy( name =\u003E 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$\u0022[Weapon Animator] rotating weapon pivot \u0027{target}\u0027: \u0022\n\t\t\t\t\u002B $\u0022parent expected=\u0027{expected.ParentName}\u0027, actual=\u0027{actualParent?.Name ?? \u0022\u0022}\u0027, \u0022\n\t\t\t\t\u002B $\u0022authoredLocal={DescribeTransform( skeleton.GetBindLocal( expected ) )}, \u0022\n\t\t\t\t\u002B $\u0022exportLocal={DescribeTransform( compilerLocal[target] )}, \u0022\n\t\t\t\t\u002B $\u0022compiledLocal={DescribeTransform( actualLocal )}, \u0022\n\t\t\t\t\u002B $\u0022compiledModel={DescribeTransform( actual?.LocalTransform ?? Transform.Zero )}.\u0022 );\n\t\t}\n\n\t\tif ( targets.Length \u003E 0 )\n\t\t{\n\t\t\tdiagnostics.Add( Diagnostic(\n\t\t\t\tValidationSeverity.Info,\n\t\t\t\t\u0022inspect.rotation_pivots\u0022,\n\t\t\t\t$\u0022Verified {targets.Length} rotation-driven weapon bone pivot(s) in compiled bind space.\u0022,\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 ) =\u003E\n\t\t(left.Forward - right.Forward).Length \u003E tolerance\n\t\t\t|| (left.Up - right.Up).Length \u003E tolerance;\n\n\tprivate static string DescribeTransform( Transform transform ) =\u003E\n\t\t$\u0022pos({transform.Position.x:0.####},{transform.Position.y:0.####},{transform.Position.z:0.####}) \u0022\n\t\t\u002B $\u0022rot({transform.Rotation.x:0.####},{transform.Rotation.y:0.####},\u0022\n\t\t\u002B $\u0022{transform.Rotation.z:0.####},{transform.Rotation.w:0.####}) \u0022\n\t\t\u002B $\u0022scale({transform.Scale.x:0.####},{transform.Scale.y:0.####},\u0022\n\t\t\u002B $\u0022{transform.Scale.z:0.####})\u0022;\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( \u0022The current project\u0027s Assets directory is unavailable.\u0022 );\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( \u0027\\\\\u0027, \u0027/\u0027 ).TrimStart( \u0027/\u0027 );\n\t\tif ( string.IsNullOrWhiteSpace( configured ) )\n\t\t\tconfigured = document.Output.GetDefaultRelativeFolder();\n\t\tif ( configured.Length \u003E= 2\n\t\t\t\u0026\u0026 char.IsLetter( configured[0] )\n\t\t\t\u0026\u0026 configured[1] == \u0027:\u0027 )\n\t\t{\n\t\t\tthrow new InvalidOperationException(\n\t\t\t\t\u0022Generated output must use a path relative to the project\u0027s Assets folder.\u0022 );\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( \u0027/\u0027, Path.DirectorySeparatorChar ) ) );\n\t\tvar relative = Path.GetRelativePath( assetsRoot, full );\n\t\tif ( Path.IsPathRooted( relative )\n\t\t\t|| relative.Equals( \u0022..\u0022, StringComparison.Ordinal )\n\t\t\t|| relative.StartsWith( $\u0022..{Path.DirectorySeparatorChar}\u0022, StringComparison.Ordinal )\n\t\t\t|| relative.StartsWith( $\u0022..{Path.AltDirectorySeparatorChar}\u0022, StringComparison.Ordinal ) )\n\t\t\tthrow new InvalidOperationException( \u0022Generated output must stay inside the project\u0027s Assets folder.\u0022 );\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\u003CGenerationManifest\u003E(\n\t\t\tFile.ReadAllText( manifestPath ) );\n\t\tif ( manifest is null )\n\t\t\tthrow new InvalidDataException( $\u0022\u0027{manifestPath}\u0027 does not contain a valid manifest.\u0022 );\n\n\t\tdocument.Manifest = manifest;\n\t}\n\n\tprivate static string InputHash( WeaponAnimationDocument document )\n\t{\n\t\tvar clone = Json.Deserialize\u003CWeaponAnimationDocument\u003E( Json.Serialize( document ) )\n\t\t\t?? throw new InvalidOperationException( \u0022Could not clone the weapon animation document.\u0022 );\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\u003Cstring\u003E generatedSourcePaths,\n\t\tIReadOnlyDictionary\u003Cstring, string\u003E textFiles,\n\t\tList\u003CGenerationDiagnostic\u003E diagnostics,\n\t\tDictionary\u003Cstring, byte[]\u003E backups,\n\t\tHashSet\u003Cstring\u003E 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\u003CGeneratedFileRecord\u003E();\n\t\tforeach ( var path in generatedSourcePaths.OrderBy( path =\u003E 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( \u0027\\\\\u0027, \u0027/\u0027 ),\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( \u0027.\u0027 )\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\u003CWeaponAnimationDocument\u003E(\n\t\t\tJson.Serialize( document ) )\n\t\t\t?? throw new InvalidOperationException(\n\t\t\t\t\u0022Could not clone the weapon animation document.\u0022 );\n\t\tsnapshot.Manifest = Json.Deserialize\u003CGenerationManifest\u003E(\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 ) =\u003E\n\t\tConvert.ToHexString( SHA256.HashData( Encoding.UTF8.GetBytes( value ) ) )\n\t\t\t.ToLowerInvariant();\n\n\tprivate static void RestoreGeneratedFiles(\n\t\tIEnumerable\u003Cstring\u003E newFiles,\n\t\tIReadOnlyDictionary\u003Cstring, byte[]\u003E 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$\u0022[Weapon Animator] could not requeue restored asset \u0027{source}\u0027: {ex.Message}\u0022 );\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate static GenerationResult CancelledResult(\n\t\tValidationReport validation,\n\t\tstring outputFolder = \u0022\u0022 ) =\u003E 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\u0022generation.cancelled\u0022,\n\t\t\t\t\u0022Generation was cancelled before any output files were changed.\u0022 )\n\t\t]\n\t};\n\n\tprivate static GenerationResult Failed(\n\t\tValidationReport validation,\n\t\tstring code,\n\t\tstring message ) =\u003E 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 = \u0022\u0022 ) =\u003E 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 = \u0022materials/tools/toolsinvisible.vmat\u0022;\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( \u0022The animation host skeleton contains no bones.\u0022 );\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 \u002B 1];\n\t\tvar poses = new IReadOnlyDictionary\u003Cstring, Transform\u003E[frameCount \u002B 1];\n\t\tfor ( var frame = 0; frame \u003C= frameCount; frame\u002B\u002B )\n\t\t{\n\t\t\tcancellationToken.ThrowIfCancellationRequested();\n\t\t\tif ( (frame \u0026 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 = $\u0022animation:{clip.Id}\u0022;\n\t\tvar rootId = Id( $\u0022{prefix}:root\u0022 );\n\t\tvar modelId = Id( $\u0022{prefix}:model\u0022 );\n\t\tvar modelTransformId = Id( $\u0022{prefix}:model-transform\u0022 );\n\t\tvar baseStateId = Id( $\u0022{prefix}:base-state\u0022 );\n\t\tvar baseModelTransformId = Id( $\u0022{prefix}:base-model-transform\u0022 );\n\t\tvar animationListId = Id( $\u0022{prefix}:animation-list\u0022 );\n\t\tvar clipId = Id( $\u0022{prefix}:clip\u0022 );\n\t\tvar timeFrameId = Id( $\u0022{prefix}:time-frame\u0022 );\n\t\tvar builder = new StringBuilder();\n\n\t\tbuilder.AppendLine( \u0022\u003C!-- dmx encoding keyvalues2 4 format model 22 --\u003E\u0022 );\n\t\tbuilder.AppendLine( \u0022\\\u0022DmElement\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, rootId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022root\u0022 );\n\t\tAttribute( builder, 1, \u0022skeleton\u0022, \u0022element\u0022, modelId );\n\t\tAttribute( builder, 1, \u0022animationList\u0022, \u0022element\u0022, animationListId );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeModel\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, modelId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022weapon_animation_host\u0022 );\n\t\tAttribute( builder, 1, \u0022transform\u0022, \u0022element\u0022, modelTransformId );\n\t\tAttribute( builder, 1, \u0022shape\u0022, \u0022element\u0022, \u0022\u0022 );\n\t\tAttribute( builder, 1, \u0022visible\u0022, \u0022bool\u0022, \u00221\u0022 );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022children\u0022,\n\t\t\tskeleton.Bones\n\t\t\t\t.Where( bone =\u003E string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t|| !skeleton.ByName.ContainsKey( bone.ParentName ) )\n\t\t\t\t.Select( bone =\u003E AnimationJointId( prefix, bone.Index ) ) );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022jointList\u0022,\n\t\t\tnew[] { modelId }.Concat(\n\t\t\t\tskeleton.Bones.Select( bone =\u003E AnimationJointId( prefix, bone.Index ) ) ) );\n\t\tElementArray( builder, 1, \u0022baseStates\u0022, [baseStateId] );\n\t\tAttribute( builder, 1, \u0022upAxis\u0022, \u0022string\u0022, \u0022Z\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t\\\u0022axisSystem\\\u0022 \\\u0022DmeAxisSystem\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t{\u0022 );\n\t\tAttribute( builder, 2, \u0022id\u0022, \u0022elementid\u0022, Id( $\u0022{prefix}:axis-system\u0022 ) );\n\t\tAttribute( builder, 2, \u0022name\u0022, \u0022string\u0022, \u0022\u0022 );\n\t\tAttribute( builder, 2, \u0022upAxis\u0022, \u0022int\u0022, \u00223\u0022 );\n\t\tAttribute( builder, 2, \u0022forwardParity\u0022, \u0022int\u0022, \u00221\u0022 );\n\t\tAttribute( builder, 2, \u0022coordSys\u0022, \u0022int\u0022, \u00220\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t}\u0022 );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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, \u0022model\u0022, 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, \u0022model\u0022, 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( \u0022\\\u0022DmeTransformList\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, baseStateId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022base\u0022 );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022transforms\u0022,\n\t\t\tnew[] { baseModelTransformId }.Concat(\n\t\t\t\tskeleton.Bones.Select( bone =\u003E\n\t\t\t\t\tAnimationBaseTransformId( prefix, bone.Index ) ) ) );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeAnimationList\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, animationListId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, clip.Name );\n\t\tElementArray( builder, 1, \u0022animations\u0022, [clipId] );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeChannelsClip\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, clipId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, WeaponAnimationNames.SequenceName( clip ) );\n\t\tAttribute( builder, 1, \u0022timeFrame\u0022, \u0022element\u0022, timeFrameId );\n\t\tAttribute( builder, 1, \u0022color\u0022, \u0022color\u0022, \u00220 0 0 0\u0022 );\n\t\tAttribute( builder, 1, \u0022text\u0022, \u0022string\u0022, \u0022\u0022 );\n\t\tAttribute( builder, 1, \u0022mute\u0022, \u0022bool\u0022, \u00220\u0022 );\n\t\tElementArray( builder, 1, \u0022trackGroups\u0022, [] );\n\t\tAttribute( builder, 1, \u0022displayScale\u0022, \u0022float\u0022, \u00221\u0022 );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022channels\u0022,\n\t\t\tskeleton.Bones.SelectMany( bone =\u003E new[]\n\t\t\t{\n\t\t\t\tAnimationChannelId( prefix, bone.Index, \u0022position\u0022 ),\n\t\t\t\tAnimationChannelId( prefix, bone.Index, \u0022orientation\u0022 ),\n\t\t\t\tAnimationChannelId( prefix, bone.Index, \u0022scale\u0022 )\n\t\t\t} ) );\n\t\tAttribute( builder, 1, \u0022frameRate\u0022, \u0022float\u0022, F( sampleRate ) );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeTimeFrame\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, timeFrameId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022timeFrame\u0022 );\n\t\tAttribute( builder, 1, \u0022start\u0022, \u0022time\u0022, \u00220\u0022 );\n\t\tAttribute( builder, 1, \u0022duration\u0022, \u0022time\u0022, F( clip.Duration ) );\n\t\tAttribute( builder, 1, \u0022offset\u0022, \u0022time\u0022, \u00220\u0022 );\n\t\tAttribute( builder, 1, \u0022scale\u0022, \u0022float\u0022, \u00221\u0022 );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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 =\u003E 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\u0022position\u0022,\n\t\t\t\ttimes,\n\t\t\t\tvalues.Select( value =\u003E 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 =\u003E 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 =\u003E F( value.Scale.x ) ).ToArray() );\n\t\t}\n\n\t\treturn builder.ToString();\n\t}\n\n\tinternal static IReadOnlyDictionary\u003Cstring, Transform\u003E BuildCompilerPoseLocals(\n\t\tHostSkeleton skeleton,\n\t\tIReadOnlyDictionary\u003Cstring, Transform\u003E authoredLocal )\n\t{\n\t\tvar authoredModel = BuildModelTransforms( skeleton, authoredLocal );\n\t\tvar exportLocal = new Dictionary\u003Cstring, Transform\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tvar exportModel = new Dictionary\u003Cstring, Transform\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tvar pending = skeleton.Bones.ToList();\n\t\twhile ( pending.Count \u003E 0 )\n\t\t{\n\t\t\tvar progressed = false;\n\t\t\tfor ( var i = pending.Count - 1; i \u003E= 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\u0026\u0026 skeleton.ByName.ContainsKey( bone.ParentName )\n\t\t\t\t\t\u0026\u0026 !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$\u0022The animation host contains a cyclic pose hierarchy near \u0027{pending[0].Name}\u0027.\u0022 );\n\t\t}\n\n\t\treturn exportLocal;\n\t}\n\n\tprivate static IReadOnlyDictionary\u003Cstring, Transform\u003E BuildModelTransforms(\n\t\tHostSkeleton skeleton,\n\t\tIReadOnlyDictionary\u003Cstring, Transform\u003E local )\n\t{\n\t\tvar model = new Dictionary\u003Cstring, Transform\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tvar pending = skeleton.Bones.ToList();\n\t\twhile ( pending.Count \u003E 0 )\n\t\t{\n\t\t\tvar progressed = false;\n\t\t\tfor ( var i = pending.Count - 1; i \u003E= 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\u0026\u0026 skeleton.ByName.ContainsKey( bone.ParentName )\n\t\t\t\t\t\u0026\u0026 !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$\u0022The animation host contains a cyclic pose hierarchy near \u0027{pending[0].Name}\u0027.\u0022 );\n\t\t}\n\n\t\treturn model;\n\t}\n\n\tprivate static Transform ComposeLocal( Transform parent, Transform local ) =\u003E 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 ) \u0026\u0026 MathF.Abs( bindValue ) \u003E 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 =\u003E\n\t\t\tx.RenderMode == VisibilityRenderMode.BoneBranch\n\t\t\t\u0026\u0026 !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\u0026\u0026 definition.Id.Equals(\n\t\t\t\t\tdocument.Rig.SourceSkeletonRootId,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase )\n\t\t\t\t\t? \u0022weapon_root\u0022\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 \u002B 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( \u0022The animation host skeleton contains no bones.\u0022 );\n\n\t\tvar rootId = Id( \u0022root\u0022 );\n\t\tvar modelId = Id( \u0022model\u0022 );\n\t\tvar modelTransformId = Id( \u0022model-transform\u0022 );\n\t\tvar meshDagId = Id( \u0022mesh-dag\u0022 );\n\t\tvar meshTransformId = Id( \u0022mesh-transform\u0022 );\n\t\tvar meshId = Id( \u0022mesh\u0022 );\n\t\tvar vertexDataId = Id( \u0022vertex-data\u0022 );\n\t\tvar faceSetId = Id( \u0022face-set\u0022 );\n\t\tvar materialId = Id( \u0022material\u0022 );\n\t\tvar compilerBindLocal = skeleton.BuildCompilerBindLocalTransforms();\n\t\tvar builder = new StringBuilder();\n\n\t\tbuilder.AppendLine( \u0022\u003C!-- dmx encoding keyvalues2 4 format model 22 --\u003E\u0022 );\n\t\tbuilder.AppendLine( \u0022\\\u0022DmElement\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, rootId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022root\u0022 );\n\t\tAttribute( builder, 1, \u0022model\u0022, \u0022element\u0022, modelId );\n\t\tAttribute( builder, 1, \u0022skeleton\u0022, \u0022element\u0022, modelId );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeModel\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, modelId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022weapon_animation_host\u0022 );\n\t\tTransformElement( builder, 1, modelTransformId, \u0022model\u0022, Transform.Zero );\n\t\tAttribute( builder, 1, \u0022visible\u0022, \u0022bool\u0022, \u00221\u0022 );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022children\u0022,\n\t\t\tskeleton.Bones\n\t\t\t\t.Where( bone =\u003E string.IsNullOrWhiteSpace( bone.ParentName )\n\t\t\t\t\t|| !skeleton.ByName.ContainsKey( bone.ParentName ) )\n\t\t\t\t.Select( bone =\u003E JointId( bone.Index ) )\n\t\t\t\t.Append( meshDagId ) );\n\t\tElementArray( builder, 1, \u0022jointList\u0022, skeleton.Bones.Select( bone =\u003E JointId( bone.Index ) ) );\n\t\tAttribute( builder, 1, \u0022upAxis\u0022, \u0022string\u0022, \u0022Z\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t\\\u0022axisSystem\\\u0022 \\\u0022DmeAxisSystem\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t{\u0022 );\n\t\tAttribute( builder, 2, \u0022id\u0022, \u0022elementid\u0022, Id( \u0022axis-system\u0022 ) );\n\t\tAttribute( builder, 2, \u0022name\u0022, \u0022string\u0022, \u0022\u0022 );\n\t\tAttribute( builder, 2, \u0022upAxis\u0022, \u0022int\u0022, \u00223\u0022 );\n\t\tAttribute( builder, 2, \u0022forwardParity\u0022, \u0022int\u0022, \u00221\u0022 );\n\t\tAttribute( builder, 2, \u0022coordSys\u0022, \u0022int\u0022, \u00220\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t}\u0022 );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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( \u0022\\\u0022DmeDag\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, meshDagId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022host_reference_triangle\u0022 );\n\t\tTransformElement( builder, 1, meshTransformId, \u0022host_reference_triangle\u0022, Transform.Zero );\n\t\tAttribute( builder, 1, \u0022shape\u0022, \u0022element\u0022, meshId );\n\t\tAttribute( builder, 1, \u0022visible\u0022, \u0022bool\u0022, \u00221\u0022 );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeMesh\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, meshId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022host_reference_triangle\u0022 );\n\t\tAttribute( builder, 1, \u0022visible\u0022, \u0022bool\u0022, \u00221\u0022 );\n\t\tAttribute( builder, 1, \u0022currentState\u0022, \u0022element\u0022, vertexDataId );\n\t\tElementArray( builder, 1, \u0022baseStates\u0022, [vertexDataId] );\n\t\tbuilder.AppendLine( \u0022\\t\\\u0022faceSets\\\u0022 \\\u0022element_array\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t[\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t\\t\\\u0022DmeFaceSet\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t\\t{\u0022 );\n\t\tAttribute( builder, 3, \u0022id\u0022, \u0022elementid\u0022, faceSetId );\n\t\tAttribute( builder, 3, \u0022name\u0022, \u0022string\u0022, CarrierMaterial );\n\t\tIntArray( builder, 3, \u0022faces\u0022, CarrierFaces( skeleton ) );\n\t\tbuilder.AppendLine( \u0022\\t\\t\\t\\\u0022material\\\u0022 \\\u0022DmeMaterial\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t\\t\\t{\u0022 );\n\t\tAttribute( builder, 4, \u0022id\u0022, \u0022elementid\u0022, materialId );\n\t\tAttribute( builder, 4, \u0022name\u0022, \u0022string\u0022, CarrierMaterial );\n\t\tAttribute( builder, 4, \u0022mtlName\u0022, \u0022string\u0022, CarrierMaterial );\n\t\tbuilder.AppendLine( \u0022\\t\\t\\t}\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t\\t}\u0022 );\n\t\tbuilder.AppendLine( \u0022\\t]\u0022 );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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( \u0022\\\u0022DmeJoint\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, AnimationJointId( prefix, bone.Index ) );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, bone.Name );\n\t\tAttribute(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022transform\u0022,\n\t\t\t\u0022element\u0022,\n\t\t\tAnimationTransformId( prefix, bone.Index ) );\n\t\tAttribute( builder, 1, \u0022shape\u0022, \u0022element\u0022, \u0022\u0022 );\n\t\tAttribute( builder, 1, \u0022visible\u0022, \u0022bool\u0022, \u00221\u0022 );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022children\u0022,\n\t\t\tskeleton.Bones\n\t\t\t\t.Where( child =\u003E child.ParentName.Equals(\n\t\t\t\t\tbone.Name,\n\t\t\t\t\tStringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t.Select( child =\u003E AnimationJointId( prefix, child.Index ) ) );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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( $\u0022{prefix}:log:{bone.Index}:{attribute}\u0022 );\n\t\tvar layerId = Id( $\u0022{prefix}:layer:{bone.Index}:{attribute}\u0022 );\n\t\tvar transformId = AnimationTransformId( prefix, bone.Index );\n\n\t\tWriteChannelHeader(\n\t\t\tbuilder,\n\t\t\tchannelId,\n\t\t\t$\u0022{bone.Name}_p\u0022,\n\t\t\ttransformId,\n\t\t\t\u0022position\u0022,\n\t\t\tlogId );\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeVector3Log\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, logId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022vector3 log\u0022 );\n\t\tElementArray( builder, 1, \u0022layers\u0022, [layerId] );\n\t\tAttribute( builder, 1, \u0022curveinfo\u0022, \u0022element\u0022, \u0022\u0022 );\n\t\tAttribute( builder, 1, \u0022usedefaultvalue\u0022, \u0022bool\u0022, \u00220\u0022 );\n\t\tAttribute( builder, 1, \u0022defaultvalue\u0022, \u0022vector3\u0022, values[0] );\n\t\tTimeArray( builder, 1, \u0022bookmarksX\u0022, [] );\n\t\tTimeArray( builder, 1, \u0022bookmarksY\u0022, [] );\n\t\tTimeArray( builder, 1, \u0022bookmarksZ\u0022, [] );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeVector3LogLayer\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, layerId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022vector3 log\u0022 );\n\t\tTimeArray( builder, 1, \u0022times\u0022, times );\n\t\tIntArray( builder, 1, \u0022curvetypes\u0022, [] );\n\t\tVectorArray( builder, 1, \u0022values\u0022, \u0022vector3_array\u0022, values );\n\t\tAttribute( builder, 1, \u0022compressed\u0022, \u0022binary\u0022, \u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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 = \u0022orientation\u0022;\n\t\tvar channelId = AnimationChannelId( prefix, bone.Index, attribute );\n\t\tvar logId = Id( $\u0022{prefix}:log:{bone.Index}:{attribute}\u0022 );\n\t\tvar layerId = Id( $\u0022{prefix}:layer:{bone.Index}:{attribute}\u0022 );\n\t\tvar transformId = AnimationTransformId( prefix, bone.Index );\n\n\t\tWriteChannelHeader(\n\t\t\tbuilder,\n\t\t\tchannelId,\n\t\t\t$\u0022{bone.Name}_o\u0022,\n\t\t\ttransformId,\n\t\t\tattribute,\n\t\t\tlogId );\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeQuaternionLog\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, logId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022quaternion log\u0022 );\n\t\tElementArray( builder, 1, \u0022layers\u0022, [layerId] );\n\t\tAttribute( builder, 1, \u0022curveinfo\u0022, \u0022element\u0022, \u0022\u0022 );\n\t\tAttribute( builder, 1, \u0022usedefaultvalue\u0022, \u0022bool\u0022, \u00220\u0022 );\n\t\tAttribute( builder, 1, \u0022defaultvalue\u0022, \u0022quaternion\u0022, values[0] );\n\t\tTimeArray( builder, 1, \u0022bookmarksX\u0022, [] );\n\t\tTimeArray( builder, 1, \u0022bookmarksY\u0022, [] );\n\t\tTimeArray( builder, 1, \u0022bookmarksZ\u0022, [] );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeQuaternionLogLayer\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, layerId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022quaternion log\u0022 );\n\t\tTimeArray( builder, 1, \u0022times\u0022, times );\n\t\tIntArray( builder, 1, \u0022curvetypes\u0022, [] );\n\t\tVectorArray( builder, 1, \u0022values\u0022, \u0022quaternion_array\u0022, values );\n\t\tAttribute( builder, 1, \u0022compressed\u0022, \u0022binary\u0022, \u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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 = \u0022scale\u0022;\n\t\tvar channelId = AnimationChannelId( prefix, bone.Index, attribute );\n\t\tvar logId = Id( $\u0022{prefix}:log:{bone.Index}:{attribute}\u0022 );\n\t\tvar layerId = Id( $\u0022{prefix}:layer:{bone.Index}:{attribute}\u0022 );\n\t\tvar transformId = AnimationTransformId( prefix, bone.Index );\n\n\t\tWriteChannelHeader(\n\t\t\tbuilder,\n\t\t\tchannelId,\n\t\t\t$\u0022{bone.Name}_s\u0022,\n\t\t\ttransformId,\n\t\t\tattribute,\n\t\t\tlogId );\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeFloatLog\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, logId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022float log\u0022 );\n\t\tElementArray( builder, 1, \u0022layers\u0022, [layerId] );\n\t\tAttribute( builder, 1, \u0022curveinfo\u0022, \u0022element\u0022, \u0022\u0022 );\n\t\tAttribute( builder, 1, \u0022usedefaultvalue\u0022, \u0022bool\u0022, \u00220\u0022 );\n\t\tAttribute( builder, 1, \u0022defaultvalue\u0022, \u0022float\u0022, values[0] );\n\t\tTimeArray( builder, 1, \u0022bookmarks\u0022, [] );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\n\t\tbuilder.AppendLine();\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeFloatLogLayer\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, layerId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022float log\u0022 );\n\t\tTimeArray( builder, 1, \u0022times\u0022, times );\n\t\tIntArray( builder, 1, \u0022curvetypes\u0022, [] );\n\t\tVectorArray( builder, 1, \u0022values\u0022, \u0022float_array\u0022, values );\n\t\tAttribute( builder, 1, \u0022compressed\u0022, \u0022binary\u0022, \u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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( \u0022\\\u0022DmeChannel\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, channelId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, name );\n\t\tAttribute( builder, 1, \u0022fromElement\u0022, \u0022element\u0022, \u0022\u0022 );\n\t\tAttribute(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022fromAttribute\u0022,\n\t\t\t\u0022string\u0022,\n\t\t\tattribute switch\n\t\t\t{\n\t\t\t\t\u0022position\u0022 =\u003E \u0022valuePosition\u0022,\n\t\t\t\t\u0022orientation\u0022 =\u003E \u0022valueOrientation\u0022,\n\t\t\t\t_ =\u003E \u0022value\u0022\n\t\t\t} );\n\t\tAttribute( builder, 1, \u0022fromIndex\u0022, \u0022int\u0022, \u00220\u0022 );\n\t\tAttribute( builder, 1, \u0022toElement\u0022, \u0022element\u0022, transformId );\n\t\tAttribute( builder, 1, \u0022toAttribute\u0022, \u0022string\u0022, attribute );\n\t\tAttribute( builder, 1, \u0022toIndex\u0022, \u0022int\u0022, \u00220\u0022 );\n\t\tAttribute( builder, 1, \u0022mode\u0022, \u0022int\u0022, \u00221\u0022 );\n\t\tAttribute( builder, 1, \u0022log\u0022, \u0022element\u0022, logId );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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( \u0022\\\u0022DmeJoint\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, JointId( bone.Index ) );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, bone.Name );\n\t\tTransformElement(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\tId( $\u0022joint-transform:{bone.Index}\u0022 ),\n\t\t\tbone.Name,\n\t\t\tbindLocal );\n\t\tAttribute( builder, 1, \u0022visible\u0022, \u0022bool\u0022, \u00221\u0022 );\n\t\tElementArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022children\u0022,\n\t\t\tskeleton.Bones\n\t\t\t\t.Where( child =\u003E child.ParentName.Equals( bone.Name, StringComparison.OrdinalIgnoreCase ) )\n\t\t\t\t.Select( child =\u003E JointId( child.Index ) ) );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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 \u003C skeleton.Bones.Count; boneIndex\u002B\u002B )\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] = \u00220 0 0\u0022;\n\t\t\tpositions[vertex \u002B 1] = \u00220.001 0 0\u0022;\n\t\t\tpositions[vertex \u002B 2] = \u00220 0.001 0\u0022;\n\t\t\tnormals[vertex] = normals[vertex \u002B 1] = normals[vertex \u002B 2] = \u00220 0 1\u0022;\n\t\t\ttexcoords[vertex] = \u00220 0\u0022;\n\t\t\ttexcoords[vertex \u002B 1] = \u00221 0\u0022;\n\t\t\ttexcoords[vertex \u002B 2] = \u00220 1\u0022;\n\t\t\tindices[vertex] = vertex;\n\t\t\tindices[vertex \u002B 1] = vertex \u002B 1;\n\t\t\tindices[vertex \u002B 2] = vertex \u002B 2;\n\t\t\tweights[vertex] = weights[vertex \u002B 1] = weights[vertex \u002B 2] = 1.0f;\n\t\t\tblendIndices[vertex] = blendIndices[vertex \u002B 1] = blendIndices[vertex \u002B 2] = boneIndex;\n\t\t}\n\n\t\tbuilder.AppendLine( \u0022\\\u0022DmeVertexData\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, vertexDataId );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, \u0022bind\u0022 );\n\t\tStringArray(\n\t\t\tbuilder,\n\t\t\t1,\n\t\t\t\u0022vertexFormat\u0022,\n\t\t\t[\u0022position$0\u0022, \u0022normal$0\u0022, \u0022texcoord$0\u0022, \u0022blendweights$0\u0022, \u0022blendindices$0\u0022] );\n\t\tAttribute( builder, 1, \u0022jointCount\u0022, \u0022int\u0022, \u00221\u0022 );\n\t\tAttribute( builder, 1, \u0022flipVCoordinates\u0022, \u0022bool\u0022, \u00221\u0022 );\n\t\tVectorArray( builder, 1, \u0022position$0\u0022, \u0022vector3_array\u0022, positions );\n\t\tIntArray( builder, 1, \u0022position$0Indices\u0022, indices );\n\t\tVectorArray( builder, 1, \u0022normal$0\u0022, \u0022vector3_array\u0022, normals );\n\t\tIntArray( builder, 1, \u0022normal$0Indices\u0022, indices );\n\t\tVectorArray( builder, 1, \u0022texcoord$0\u0022, \u0022vector2_array\u0022, texcoords );\n\t\tIntArray( builder, 1, \u0022texcoord$0Indices\u0022, indices );\n\t\tFloatArray( builder, 1, \u0022blendweights$0\u0022, weights );\n\t\tIntArray( builder, 1, \u0022blendindices$0\u0022, blendIndices );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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 \u003C skeleton.Bones.Count; boneIndex\u002B\u002B )\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 \u002B 1] = vertex \u002B 1;\n\t\t\tfaces[face \u002B 2] = vertex \u002B 2;\n\t\t\tfaces[face \u002B 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( \u0027\\t\u0027, indent );\n\t\tbuilder.Append( tabs ).AppendLine( \u0022\\\u0022transform\\\u0022 \\\u0022DmeTransform\\\u0022\u0022 );\n\t\tbuilder.Append( tabs ).AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, indent \u002B 1, \u0022id\u0022, \u0022elementid\u0022, id );\n\t\tAttribute( builder, indent \u002B 1, \u0022name\u0022, \u0022string\u0022, name );\n\t\tAttribute(\n\t\t\tbuilder,\n\t\t\tindent \u002B 1,\n\t\t\t\u0022position\u0022,\n\t\t\t\u0022vector3\u0022,\n\t\t\t$\u0022{F( transform.Position.x )} {F( transform.Position.y )} {F( transform.Position.z )}\u0022 );\n\t\tAttribute(\n\t\t\tbuilder,\n\t\t\tindent \u002B 1,\n\t\t\t\u0022orientation\u0022,\n\t\t\t\u0022quaternion\u0022,\n\t\t\t$\u0022{F( transform.Rotation.x )} {F( transform.Rotation.y )} \u0022\n\t\t\t\u002B $\u0022{F( transform.Rotation.z )} {F( transform.Rotation.w )}\u0022 );\n\t\tAttribute( builder, indent \u002B 1, \u0022scale\u0022, \u0022float\u0022, F( transform.Scale.x ) );\n\t\tbuilder.Append( tabs ).AppendLine( \u0022}\u0022 );\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( \u0022\\\u0022DmeTransform\\\u0022\u0022 );\n\t\tbuilder.AppendLine( \u0022{\u0022 );\n\t\tAttribute( builder, 1, \u0022id\u0022, \u0022elementid\u0022, id );\n\t\tAttribute( builder, 1, \u0022name\u0022, \u0022string\u0022, name );\n\t\tAttribute( builder, 1, \u0022position\u0022, \u0022vector3\u0022, Vector( transform.Position ) );\n\t\tAttribute( builder, 1, \u0022orientation\u0022, \u0022quaternion\u0022, Quaternion( transform.Rotation.Normal ) );\n\t\tAttribute( builder, 1, \u0022scale\u0022, \u0022float\u0022, F( transform.Scale.x ) );\n\t\tbuilder.AppendLine( \u0022}\u0022 );\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\u003Cstring\u003E values )\n\t{\n\t\tvar tabs = new string( \u0027\\t\u0027, indent );\n\t\tvar items = values.ToArray();\n\t\tbuilder.Append( tabs ).Append( \u0027\u0022\u0027 ).Append( name ).AppendLine( \u0022\\\u0022 \\\u0022element_array\\\u0022\u0022 );\n\t\tbuilder.Append( tabs ).AppendLine( \u0022[\u0022 );\n\t\tfor ( var i = 0; i \u003C items.Length; i\u002B\u002B )\n\t\t{\n\t\t\tbuilder.Append( \u0027\\t\u0027, indent \u002B 1 )\n\t\t\t\t.Append( \u0022\\\u0022element\\\u0022 \\\u0022\u0022 )\n\t\t\t\t.Append( Escape( items[i] ) )\n\t\t\t\t.Append( \u0027\u0022\u0027 );\n\t\t\tif ( i \u003C items.Length - 1 )\n\t\t\t\tbuilder.Append( \u0027,\u0027 );\n\t\t\tbuilder.AppendLine();\n\t\t}\n\t\tbuilder.Append( tabs ).AppendLine( \u0022]\u0022 );\n\t}\n\n\tprivate static void StringArray( StringBuilder builder, int indent, string name, string[] values )\n\t{\n\t\tvar tabs = new string( \u0027\\t\u0027, indent );\n\t\tbuilder.Append( tabs ).Append( \u0027\u0022\u0027 ).Append( name ).AppendLine( \u0022\\\u0022 \\\u0022string_array\\\u0022\u0022 );\n\t\tbuilder.Append( tabs ).AppendLine( \u0022[\u0022 );\n\t\tfor ( var i = 0; i \u003C values.Length; i\u002B\u002B )\n\t\t{\n\t\t\tbuilder.Append( \u0027\\t\u0027, indent \u002B 1 ).Append( \u0027\u0022\u0027 ).Append( Escape( values[i] ) ).Append( \u0027\u0022\u0027 );\n\t\t\tif ( i \u003C values.Length - 1 )\n\t\t\t\tbuilder.Append( \u0027,\u0027 );\n\t\t\tbuilder.AppendLine();\n\t\t}\n\t\tbuilder.Append( tabs ).AppendLine( \u0022]\u0022 );\n\t}\n\n\tprivate static void IntArray( StringBuilder builder, int indent, string name, int[] values ) =\u003E\n\t\tVectorArray( builder, indent, name, \u0022int_array\u0022, values.Select( x =\u003E x.ToString( Invariant ) ).ToArray() );\n\n\tprivate static void FloatArray( StringBuilder builder, int indent, string name, float[] values ) =\u003E\n\t\tVectorArray( builder, indent, name, \u0022float_array\u0022, values.Select( F ).ToArray() );\n\n\tprivate static void TimeArray( StringBuilder builder, int indent, string name, string[] values ) =\u003E\n\t\tVectorArray( builder, indent, name, \u0022time_array\u0022, 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( \u0027\\t\u0027, indent );\n\t\tbuilder.Append( tabs ).Append( \u0027\u0022\u0027 ).Append( name ).Append( \u0022\\\u0022 \\\u0022\u0022 ).Append( type ).AppendLine( \u0022\\\u0022\u0022 );\n\t\tbuilder.Append( tabs ).AppendLine( \u0022[\u0022 );\n\t\tfor ( var i = 0; i \u003C values.Length; i\u002B\u002B )\n\t\t{\n\t\t\tbuilder.Append( \u0027\\t\u0027, indent \u002B 1 ).Append( \u0027\u0022\u0027 ).Append( values[i] ).Append( \u0027\u0022\u0027 );\n\t\t\tif ( i \u003C values.Length - 1 )\n\t\t\t\tbuilder.Append( \u0027,\u0027 );\n\t\t\tbuilder.AppendLine();\n\t\t}\n\t\tbuilder.Append( tabs ).AppendLine( \u0022]\u0022 );\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( \u0027\\t\u0027, indent )\n\t\t\t.Append( \u0027\u0022\u0027 ).Append( name ).Append( \u0027\u0022\u0027 );\n\t\tif ( !string.IsNullOrWhiteSpace( type ) )\n\t\t\tbuilder.Append( \u0022 \\\u0022\u0022 ).Append( type ).Append( \u0027\u0022\u0027 );\n\t\tbuilder.Append( \u0022 \\\u0022\u0022 ).Append( Escape( value ) ).AppendLine( \u0022\\\u0022\u0022 );\n\t}\n\n\tprivate static string JointId( int index ) =\u003E Id( $\u0022joint:{index}\u0022 );\n\tprivate static string AnimationJointId( string prefix, int index ) =\u003E\n\t\tId( $\u0022{prefix}:joint:{index}\u0022 );\n\tprivate static string AnimationTransformId( string prefix, int index ) =\u003E\n\t\tId( $\u0022{prefix}:transform:{index}\u0022 );\n\tprivate static string AnimationBaseTransformId( string prefix, int index ) =\u003E\n\t\tId( $\u0022{prefix}:base-transform:{index}\u0022 );\n\tprivate static string AnimationChannelId( string prefix, int index, string attribute ) =\u003E\n\t\tId( $\u0022{prefix}:channel:{index}:{attribute}\u0022 );\n\n\tprivate static string Id( string key )\n\t{\n\t\tvar bytes = SHA256.HashData( Encoding.UTF8.GetBytes( $\u0022SboxWeaponAnimator.DmxReference:{key}\u0022 ) );\n\t\treturn new Guid( bytes.AsSpan( 0, 16 ) ).ToString();\n\t}\n\n\tprivate static string F( float value ) =\u003E value.ToString( \u00220.######\u0022, Invariant );\n\tprivate static string Vector( Vector3 value ) =\u003E\n\t\t$\u0022{F( value.x )} {F( value.y )} {F( value.z )}\u0022;\n\tprivate static string Quaternion( Rotation value ) =\u003E\n\t\t$\u0022{F( value.x )} {F( value.y )} {F( value.z )} {F( value.w )}\u0022;\n\tprivate static string Escape( string value ) =\u003E value.Replace( \u0022\\\\\u0022, \u0022\\\\\\\\\u0022 ).Replace( \u0022\\\u0022\u0022, \u0022\\\\\\\u0022\u0022 );\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 = \u0022@anchor:\u0022;\n\n\tpublic static string Anchor( AnchorKind kind ) =\u003E $\u0022{AnchorPrefix}{kind}\u0022;\n\n\t/// \u003Csummary\u003E\n\t/// Custom anchors carry their id in the token, because a weapon may hold several of them.\n\t/// \u003C/summary\u003E\n\tpublic static string Anchor( WeaponAnchor anchor ) =\u003E\n\t\tanchor.Kind == AnchorKind.Custom\n\t\t\t? $\u0022{AnchorPrefix}{AnchorKind.Custom}:{anchor.Id:N}\u0022\n\t\t\t: Anchor( anchor.Kind );\n\n\tpublic static string DisplayName( AnchorKind kind ) =\u003E kind switch\n\t{\n\t\tAnchorKind.Grip =\u003E \u0022Primary grip\u0022,\n\t\tAnchorKind.RearBore =\u003E \u0022Alignment marker \u2014 rear\u0022,\n\t\tAnchorKind.FrontBore =\u003E \u0022Alignment marker \u2014 front\u0022,\n\t\tAnchorKind.Muzzle =\u003E \u0022Muzzle\u0022,\n\t\tAnchorKind.Eject =\u003E \u0022Eject\u0022,\n\t\t_ =\u003E \u0022Custom anchor\u0022\n\t};\n\n\tpublic static string DisplayName( WeaponAnchor anchor ) =\u003E\n\t\tanchor.Kind == AnchorKind.Custom \u0026\u0026 !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( \u0027:\u0027 );\n\t\treturn Enum.TryParse( separator \u003E= 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( \u0027:\u0027 );\n\t\treturn separator \u003E= 0\n\t\t\t\u0026\u0026 Guid.TryParseExact( body[(separator \u002B 1)..], \u0022N\u0022, out id );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Resolves a selection token to its anchor, by id for custom anchors and by kind otherwise.\n\t/// \u003C/summary\u003E\n\tpublic static WeaponAnchor? Resolve( WeaponAnimationDocument document, string control ) =\u003E\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\u003Cfloat\u003E _getValue;\n\tprivate readonly Action _begin;\n\tprivate readonly Action\u003Cfloat\u003E _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\u003Cfloat\u003E getValue,\n\t\tAction begin,\n\t\tAction\u003Cfloat\u003E 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 = $\u0022Drag {_text} horizontally to adjust\u0022;\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 \u002B (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\u003Cstring, WeaponAnimatorButton\u003E _boneButtons =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate string _lastSelectedBone = \u0022\u0022;\n\tprivate string _boneStructureSignature = \u0022\u0022;\n\tprivate string _filter = \u0022\u0022;\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\u0022Import rigged model\u0022,\n\t\t\t\u0022file_upload\u0022,\n\t\t\t() =\u003E ImportRequested?.Invoke(),\n\t\t\tthis,\n\t\t\ttrue );\n\t\tLayout.Add( import );\n\n\t\t_sourceStatus = WeaponAnimatorTheme.Label( \u0022No source selected\u0022, this, true );\n\t\t_sourceStatus.WordWrap = true;\n\t\tLayout.Add( _sourceStatus );\n\n\t\t_retainedStatus = WeaponAnimatorTheme.Label( \u0022No weapon subtree selected\u0022, 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 = \u0022Search bones\u2026\u0022,\n\t\t\tFixedHeight = 28\n\t\t};\n\t\t_search.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t_search.TextEdited \u002B= value =\u003E\n\t\t{\n\t\t\t_filter = value?.Trim() ?? \u0022\u0022;\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, \u0022Movable\u0022, _showMovable, x =\u003E _showMovable = x ) );\n\t\tfilters.Layout.Add( Toggle( filters, \u0022Structural\u0022, _showStructural, x =\u003E _showStructural = x ) );\n\t\tfilters.Layout.Add( Toggle( filters, \u0022Ignored\u0022, _showIgnored, x =\u003E _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\u0022Set selected as weapon root\u0022,\n\t\t\t\u0022account_tree\u0022,\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\u0022Include branch\u0022,\n\t\t\t\u0022add\u0022,\n\t\t\tIncludeSelectedBranch,\n\t\t\tbranchActions ) );\n\t\tbranchActions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\u0022Exclude branch\u0022,\n\t\t\t\u0022remove\u0022,\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\u0022Confirm weapon bones\u0022,\n\t\t\t\u0022verified\u0022,\n\t\t\tConfirmWeaponBones,\n\t\t\tthis,\n\t\t\ttrue ) );\n\n\t\t_controller.DocumentChanged \u002B= Refresh;\n\t\t_controller.SelectionChanged \u002B= 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? \u0022No source selected\u0022\n\t\t\t: $\u0022{source.SourcePath}\\n{_controller.Document.Rig.Bones.Count} bones \u00B7 \u0022\n\t\t\t\t\u002B $\u0022{source.Materials.Count( material =\u003E material.HasUsableTextures )}/\u0022\n\t\t\t\t\u002B $\u0022{source.Materials.Count} textured materials \u00B7 \u0022\n\t\t\t\t\u002B $\u0022{(source.Compiled ? \u0022compiled\u0022 : \u0022compile failed\u0022)}\u0022;\n\t\tvar rig = _controller.Document.Rig;\n\t\tvar retained = rig.Bones.Count( WeaponRigHierarchy.IsRetained );\n\t\tvar structural = rig.Bones.Count( x =\u003E\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? \u0022No weapon subtree selected\u0022\n\t\t\t: $\u0022{retained} retained \u00B7 {structural} structural \u00B7 {excluded} excluded\u0022\n\t\t\t\t\u002B (rig.ReviewRequired ? \u0022\\nReview and confirm the filtered weapon preview.\u0022 : \u0022\\nWeapon bones confirmed.\u0022)\n\t\t\t\t\u002B (rig.Bones.Any( x =\u003E\n\t\t\t\t\t\tx.Inclusion == WeaponBoneInclusion.Excluded \u0026\u0026 x.HasSkinInfluence )\n\t\t\t\t\t? \u0022\\nExcluded branches may affect visible geometry; verify the model before confirming.\u0022\n\t\t\t\t\t: \u0022\u0022);\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 =\u003E x.Name, StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToDictionary(\n\t\t\t\tx =\u003E x.Key,\n\t\t\t\tx =\u003E x.First(),\n\t\t\t\tStringComparer.OrdinalIgnoreCase );\n\t\tvar depths = new Dictionary\u003Cstring, int\u003E( StringComparer.OrdinalIgnoreCase );\n\n\t\tint ResolveDepth( WeaponBoneDefinition bone, HashSet\u003Cstring\u003E 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 ) \u002B 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( $\u0022{new string( \u0027 \u0027, Math.Min( depth, 8 ) * 2 )}{bone.Name}\u0022, row )\n\t\t\t{\n\t\t\t\tClicked = () =\u003E _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 = () =\u003E CycleClassification( bone ),\n\t\t\t\tFixedWidth = 34,\n\t\t\t\tToolTip = $\u0022{bone.Classification} \u00B7 {bone.Inclusion}\u0022,\n\t\t\t\tTint = bone.Classification switch\n\t\t\t\t{\n\t\t\t\t\tWeaponBoneClassification.WeaponRoot =\u003E WeaponAnimatorTheme.Coral * 0.55f,\n\t\t\t\t\tWeaponBoneClassification.Animatable =\u003E WeaponAnimatorTheme.Amber * 0.45f,\n\t\t\t\t\tWeaponBoneClassification.Structural =\u003E WeaponAnimatorTheme.SurfaceRaised,\n\t\t\t\t\t_ =\u003E 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 = \u0022\u0022;\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( \u0027|\u0027 )\n\t\t\t.Append( showMovable ? \u00271\u0027 : \u00270\u0027 )\n\t\t\t.Append( showStructural ? \u00271\u0027 : \u00270\u0027 )\n\t\t\t.Append( showIgnored ? \u00271\u0027 : \u00270\u0027 );\n\n\t\tforeach ( var bone in rig.Bones )\n\t\t{\n\t\t\tsignature\n\t\t\t\t.Append( \u0027\\n\u0027 )\n\t\t\t\t.Append( bone.Id )\n\t\t\t\t.Append( \u0027\\t\u0027 )\n\t\t\t\t.Append( bone.Name )\n\t\t\t\t.Append( \u0027\\t\u0027 )\n\t\t\t\t.Append( bone.ParentId )\n\t\t\t\t.Append( \u0027\\t\u0027 )\n\t\t\t\t.Append( bone.ParentName )\n\t\t\t\t.Append( \u0027\\t\u0027 )\n\t\t\t\t.Append( (int)bone.Classification )\n\t\t\t\t.Append( \u0027\\t\u0027 )\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tprivate void RevealIfNeeded( Widget button )\n\t{\n\t\tif ( button.Height \u003C= 0 || _boneScroll.Height \u003C= 0 )\n\t\t\treturn;\n\n\t\tvar viewportTop = _boneScroll.ScreenPosition.y;\n\t\tvar viewportBottom = viewportTop \u002B _boneScroll.Height;\n\t\tvar itemTop = button.ScreenPosition.y;\n\t\tvar itemBottom = itemTop \u002B button.Height;\n\t\tif ( itemTop \u003C viewportTop )\n\t\t\t_boneScroll.VerticalScrollbar.Value -= (viewportTop - itemTop).CeilToInt();\n\t\telse if ( itemBottom \u003E viewportBottom )\n\t\t\t_boneScroll.VerticalScrollbar.Value \u002B= (itemBottom - viewportBottom).CeilToInt();\n\t}\n\n\tprivate bool IsVisible( WeaponBoneDefinition bone )\n\t{\n\t\tif ( !string.IsNullOrWhiteSpace( _filter )\n\t\t\t\u0026\u0026 !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 =\u003E _showMovable,\n\t\t\tWeaponBoneClassification.Animatable =\u003E _showMovable,\n\t\t\tWeaponBoneClassification.Structural =\u003E _showStructural,\n\t\t\tWeaponBoneClassification.Ignored =\u003E _showIgnored,\n\t\t\t_ =\u003E 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 =\u003E WeaponBoneClassification.Structural,\n\t\t\t_ =\u003E WeaponBoneClassification.Animatable\n\t\t};\n\n\t\t_controller.Mutate( $\u0022Classify {selected.Name}\u0022, document =\u003E\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( $\u0022Set weapon root {selected}\u0022, document =\u003E\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( $\u0022Exclude branch {selected}\u0022, document =\u003E\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( $\u0022Include branch {selected}\u0022, document =\u003E\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( \u0022Confirm filtered weapon bones\u0022, document =\u003E\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 ) =\u003E value switch\n\t{\n\t\tWeaponBoneClassification.WeaponRoot =\u003E \u0022R\u0022,\n\t\tWeaponBoneClassification.Animatable =\u003E \u0022A\u0022,\n\t\tWeaponBoneClassification.Structural =\u003E \u0022S\u0022,\n\t\t_ =\u003E \u0022\u00D7\u0022\n\t};\n\n\tprivate Button Toggle( Widget parent, string text, bool value, Action\u003Cbool\u003E 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 = () =\u003E\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( \u0022background-color: transparent; border: none;\u0022 );\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\u003CAction\u003E _transformRefreshers = [];\n\tprivate readonly List\u003CAction\u003E _documentRefreshers = [];\n\tprivate readonly Dictionary\u003CGuid, Button\u003E _customAnchorButtons = [];\n\tprivate Widget? _customAnchorCanvas;\n\tprivate string _customAnchorSignature = \u0022\u0022;\n\tprivate Vector3 _modelDimensions;\n\n\tpublic event Action\u003CViewportPickMode, Guid\u003E? 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, \u0022SELECTION\u0022 ) );\n\t\t_selectedBone = WeaponAnimatorTheme.Label( \u0022No bone selected\u0022, canvas, true );\n\t\tcanvas.Layout.Add( _selectedBone );\n\n\t\tcanvas.Layout.Add( Section( canvas, \u0022PHYSICAL MEASUREMENT\u0022 ) );\n\t\tvar pickRow = RigAuditPanel.Row( canvas );\n\t\tpickRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\u0022Point A\u0022,\n\t\t\t\u0022looks_one\u0022,\n\t\t\t() =\u003E PickRequested?.Invoke( ViewportPickMode.MeasurementFirst, Guid.Empty ),\n\t\t\tpickRow ), 1 );\n\t\tpickRow.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\u0022Point B\u0022,\n\t\t\t\u0022looks_two\u0022,\n\t\t\t() =\u003E 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 = \u0022Known distance\u0022,\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 \u002B= CommitScalePreview;\n\t\t_knownDistance.ReturnPressed \u002B= CommitScalePreview;\n\t\tknownRow.Layout.Add( _knownDistance, 1 );\n\n\t\tvar unitButton = new WeaponAnimatorButton( \u0022in\u0022, 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( () =\u003E\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? \u0022in\u0022\n\t\t\t\t\t: \u0022cm\u0022;\n\t\t} );\n\t\tcanvas.Layout.Add( knownRow );\n\n\t\t_measurementResult = WeaponAnimatorTheme.Label( \u0022Pick two points to establish scale.\u0022, 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\u0022Apply scale\u0022,\n\t\t\t\u0022done\u0022,\n\t\t\tApplyScale,\n\t\t\tcanvas,\n\t\t\ttrue ) );\n\n\t\tcanvas.Layout.Add( Section( canvas, \u0022AUTO-ALIGN MARKERS \u00B7 OPTIONAL\u0022 ) );\n\t\tvar alignmentNote = WeaponAnimatorTheme.Label(\n\t\t\t\u0022Only needed when Auto-align should rotate an incorrectly oriented source model.\u0022,\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\talignmentNote.WordWrap = true;\n\t\tcanvas.Layout.Add( alignmentNote );\n\t\tAddPickButton( canvas, \u0022Alignment marker \u2014 rear\u0022, \u0022radio_button_unchecked\u0022, ViewportPickMode.RearBoreAnchor );\n\t\tAddPickButton( canvas, \u0022Alignment marker \u2014 front\u0022, \u0022adjust\u0022, ViewportPickMode.FrontBoreAnchor );\n\t\tvar autoAlign = WeaponAnimatorTheme.Button(\n\t\t\t\u0022Auto-align from markers\u0022,\n\t\t\t\u0022center_focus_strong\u0022,\n\t\t\t() =\u003E AutoAlignRequested?.Invoke(),\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\tautoAlign.ToolTip = \u0022Uses the rear-to-front marker direction to rotate and place the weapon\u0022;\n\t\tcanvas.Layout.Add( autoAlign );\n\t\t_alignmentResult = WeaponAnimatorTheme.Label(\n\t\t\t\u0022Skip these markers when the source orientation is already correct.\u0022,\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, \u0022GRIP ANCHOR \u00B7 REQUIRED\u0022 ) );\n\t\tvar gripNote = WeaponAnimatorTheme.Label(\n\t\t\t\u0022Seeds the default primary-hand target used on the animation page.\u0022,\n\t\t\tcanvas,\n\t\t\ttrue );\n\t\tgripNote.WordWrap = true;\n\t\tcanvas.Layout.Add( gripNote );\n\t\tAddPickButton( canvas, \u0022Primary grip\u0022, \u0022pan_tool\u0022, ViewportPickMode.GripAnchor );\n\n\t\tcanvas.Layout.Add( Section( canvas, \u0022OUTPUT ANCHORS \u00B7 OPTIONAL\u0022 ) );\n\t\tAddPickButton( canvas, \u0022Muzzle\u0022, \u0022flare\u0022, ViewportPickMode.MuzzleAnchor );\n\t\tAddPickButton( canvas, \u0022Eject\u0022, \u0022outbound\u0022, ViewportPickMode.EjectAnchor );\n\n\t\tcanvas.Layout.Add( Section( canvas, \u0022CUSTOM ANCHORS \u00B7 OPTIONAL\u0022 ) );\n\t\tvar customNote = WeaponAnimatorTheme.Label(\n\t\t\t\u0022Exported alongside muzzle and eject. Name each one to match the attachment your game \u0022\n\t\t\t\t\u002B \u0022code expects. Grip and alignment markers stay in calibration and are never exported.\u0022,\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\u0022Add custom anchor\u0022,\n\t\t\t\u0022add_location_alt\u0022,\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\u0022Clear all anchors\u0022,\n\t\t\t\u0022delete_sweep\u0022,\n\t\t\tClearAllAnchors,\n\t\t\tcanvas ) );\n\t\t_documentRefreshers.Add( () =\u003E\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\u0026\u0026 calibration.GetAnchor( AnchorKind.RearBore ) is not null\n\t\t\t\t\u0026\u0026 calibration.GetAnchor( AnchorKind.FrontBore ) is not null;\n\t\t} );\n\n\t\tcanvas.Layout.Add( Section( canvas, \u0022NUMERIC TRANSFORMS\u0022 ) );\n\t\tAddTransformFields( canvas, \u0022Physical\u0022, false );\n\t\tAddTransformFields( canvas, \u0022Viewmodel framing\u0022, true );\n\n\t\tcanvas.Layout.Add( Section( canvas, \u0022PREVIEW\u0022 ) );\n\t\tvar previewNote = WeaponAnimatorTheme.Label(\n\t\t\t\u0022Viewmodel camera is optional. It previews a fixed origin and does not author the player\u0027s camera.\u0022,\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\u0022Viewmodel camera\u0022,\n\t\t\t() =\u003E _controller.Document.Workspace.FirstPersonPreview,\n\t\t\tvalue =\u003E _controller.Mutate( \u0022Preview mode\u0022, d =\u003E d.Workspace.FirstPersonPreview = value ) ), 1 );\n\t\tmodeRow.Layout.Add( Toggle(\n\t\t\tmodeRow,\n\t\t\t\u0022Safe area\u0022,\n\t\t\t() =\u003E _controller.Document.Calibration.ShowSafeArea,\n\t\t\tvalue =\u003E _controller.Mutate( \u0022Safe area\u0022, d =\u003E 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\u0022Aspect\u0022,\n\t\t\t() =\u003E _controller.Document.Calibration.AspectGuide,\n\t\t\t[\u00224:3\u0022, \u002216:9\u0022, \u002221:9\u0022],\n\t\t\tvalue =\u003E _controller.Mutate( \u0022Aspect guide\u0022, d =\u003E d.Calibration.AspectGuide = value ) ) );\n\t\tcanvas.Layout.Add( ChoiceButton(\n\t\t\tcanvas,\n\t\t\t\u0022Up axis\u0022,\n\t\t\t() =\u003E _controller.Document.Calibration.UpAxis.ToString(),\n\t\t\tEnum.GetNames\u003CWeaponUpAxis\u003E(),\n\t\t\tvalue =\u003E _controller.Mutate(\n\t\t\t\t\u0022Alignment up axis\u0022,\n\t\t\t\td =\u003E d.Calibration.UpAxis = Enum.Parse\u003CWeaponUpAxis\u003E( value ) ) ) );\n\t\tcanvas.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\u0022Rebuild preview host\u0022,\n\t\t\t\u0022refresh\u0022,\n\t\t\t() =\u003E RebuildPreviewRequested?.Invoke(),\n\t\t\tcanvas ) );\n\n\t\tcanvas.Layout.Add( Section( canvas, \u0022CALIBRATION GATE\u0022 ) );\n\t\t_confirmationState = WeaponAnimatorTheme.Label( \u0022\u0022, 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\u0022Confirm rig and continue\u0022,\n\t\t\t\u0022arrow_forward\u0022,\n\t\t\t() =\u003E ConfirmRequested?.Invoke(),\n\t\t\tcanvas,\n\t\t\ttrue ) );\n\t\tcanvas.Layout.AddStretchCell();\n\n\t\t_controller.DocumentChanged \u002B= Refresh;\n\t\t_controller.SelectionChanged \u002B= Refresh;\n\t\t_controller.PoseChanged \u002B= 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? \u0022Anchor not set\u0022\n\t\t\t\t: $\u0022{CalibrationSelection.DisplayName( anchorKind )} \u00B7 use Move or Rotate in the viewport\u0022;\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? \u0022No control selected\u0022\n\t\t\t\t: $\u0022{document.Workspace.SelectedBone} bone\u0022;\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 \u003E 0\n\t\t\t\t? measurement.KnownDistance.ToString( \u00220.###\u0022, CultureInfo.InvariantCulture )\n\t\t\t\t: \u0022\u0022;\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? \u0022All calibration checks pass. Confirmation will snapshot the rig and seed Idle.\u0022\n\t\t\t: string.Join( \u0022\\n\u0022, report.Issues.Where( x =\u003E x.Blocking ).Take( 5 ).Select( x =\u003E $\u0022\u2022 {x.Message}\u0022 ) );\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\u0022Pick two distinct points and enter a positive known distance.\u0022;\n\t\t\treturn;\n\t\t}\n\n\t\t_measurementResult.Text =\n\t\t\t$\u0022Measured {preview.MeasuredUnits:0.###} units. Scale \u00D7{preview.UniformScale:0.####}\\n\u0022 \u002B\n\t\t\t$\u0022Original XYZ: {FormatDimensions( preview.OriginalDimensions )}\\n\u0022 \u002B\n\t\t\t$\u0022Result XYZ: {FormatDimensions( preview.ResultingDimensions )}\u0022;\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 \u003C= 0 )\n\t\t{\n\t\t\t_measurementResult.Text = \u0022Pick two distinct points and enter a positive known distance.\u0022;\n\t\t\treturn;\n\t\t}\n\n\t\t_controller.Mutate( \u0022Update scale measurement\u0022, document =\u003E\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( \u0022Apply uniform scale\u0022, document =\u003E\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 \u003C= 0 )\n\t\t\treturn false;\n\n\t\tvar measurement = _controller.Document.Calibration.Measurement;\n\t\treturn measurement.HasFirstPoint\n\t\t\t\u0026\u0026 measurement.HasSecondPoint\n\t\t\t\u0026\u0026 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\u0022Position\u0022,\n\t\t\t() =\u003E GetCalibrationTransform( framing ).Position,\n\t\t\t0.05f,\n\t\t\t(document, value) =\u003E\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\u0022Rotation\u0022,\n\t\t\t() =\u003E\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) =\u003E\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\u0022Scale\u0022,\n\t\t\t() =\u003E GetCalibrationTransform( framing ).Scale.x,\n\t\t\t0.005f,\n\t\t\t(document, value) =\u003E\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\u003CVector3\u003E getter,\n\t\tfloat sensitivity,\n\t\tAction\u003CWeaponAnimationDocument, Vector3\u003E 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[] { \u0022X\u0022, \u0022Y\u0022, \u0022Z\u0022 };\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 \u003C edits.Length; index\u002B\u002B )\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 \u002B= () =\u003E\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$\u0022{label} {axisNames[capturedIndex]}\u0022,\n\t\t\t\t\tdocument =\u003E 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() =\u003E getter()[capturedIndex],\n\t\t\t\t() =\u003E _controller.BeginContinuousEdit( $\u0022{label} {axisNames[capturedIndex]}\u0022 ),\n\t\t\t\tvalue =\u003E\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 =\u003E 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( () =\u003E\n\t\t{\n\t\t\tvar value = getter();\n\t\t\tfor ( var index = 0; index \u003C edits.Length; index\u002B\u002B )\n\t\t\t\tedits[index].Value = value[index].ToString( \u00220.###\u0022, 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\u003Cfloat\u003E getter,\n\t\tfloat sensitivity,\n\t\tAction\u003CWeaponAnimationDocument, float\u003E 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 \u002B= () =\u003E\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 =\u003E apply( document, parsed ) );\n\t\t};\n\t\tfield.Layout.Add( new ScrubHandle(\n\t\t\t\u0022XYZ\u0022,\n\t\t\tWeaponAnimatorTheme.Amber,\n\t\t\tsensitivity,\n\t\t\tgetter,\n\t\t\t() =\u003E _controller.BeginContinuousEdit( label ),\n\t\t\tvalue =\u003E _controller.UpdateContinuousEdit( document =\u003E 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( () =\u003E\n\t\t\tedit.Value = getter().ToString( \u00220.###\u0022, CultureInfo.InvariantCulture ) );\n\t\tparent.Layout.Add( row );\n\t}\n\n\tprivate Transform GetCalibrationTransform( bool framing ) =\u003E\n\t\tGetCalibrationTransform( _controller.Document, framing );\n\n\tprivate static Transform GetCalibrationTransform( WeaponAnimationDocument document, bool framing ) =\u003E\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 $\u0022{dimensions.x:0.##}\u00D7{dimensions.y:0.##}\u00D7{dimensions.z:0.##} in \u00B7 \u0022 \u002B\n\t\t\t$\u0022{centimetres.x:0.##}\u00D7{centimetres.y:0.##}\u00D7{centimetres.z:0.##} cm\u0022;\n\t}\n\n\tprivate void ToggleUnit()\n\t{\n\t\t_controller.Mutate( \u0022Measurement unit\u0022, document =\u003E\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 =\u003E AnchorKind.Grip,\n\t\t\tViewportPickMode.RearBoreAnchor =\u003E AnchorKind.RearBore,\n\t\t\tViewportPickMode.FrontBoreAnchor =\u003E AnchorKind.FrontBore,\n\t\t\tViewportPickMode.MuzzleAnchor =\u003E AnchorKind.Muzzle,\n\t\t\tViewportPickMode.EjectAnchor =\u003E AnchorKind.Eject,\n\t\t\t_ =\u003E 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() =\u003E\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\u0022\u0022,\n\t\t\t\u0022my_location\u0022,\n\t\t\t() =\u003E PickRequested?.Invoke( mode, Guid.Empty ),\n\t\t\trow );\n\t\trepick.FixedWidth = 34;\n\t\trepick.ToolTip = $\u0022Pick {name.ToLowerInvariant()} again\u0022;\n\t\trow.Layout.Add( repick );\n\t\tvar delete = WeaponAnimatorTheme.Button(\n\t\t\t\u0022\u0022,\n\t\t\t\u0022delete\u0022,\n\t\t\t() =\u003E DeleteAnchor( kind ),\n\t\t\trow );\n\t\tdelete.FixedWidth = 34;\n\t\tdelete.ToolTip = $\u0022Delete {name.ToLowerInvariant()}\u0022;\n\t\trow.Layout.Add( delete );\n\t\t_documentRefreshers.Add( () =\u003E\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 ? $\u0022Move {name}\u0022 : $\u0022Set {name}\u0022;\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( $\u0022Delete {CalibrationSelection.DisplayName( kind )} anchor\u0022, document =\u003E\n\t\t{\n\t\t\tdocument.Calibration.Anchors.RemoveAll( anchor =\u003E anchor.Kind == kind );\n\t\t\tif ( document.Workspace.SelectedControl == CalibrationSelection.Anchor( kind ) )\n\t\t\t\tdocument.Workspace.SelectedControl = \u0022\u0022;\n\t\t\tdocument.Calibration.Confirmed = false;\n\t\t} );\n\t}\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\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\u0027\\n\u0027,\n\t\t\tanchors.Select( anchor =\u003E $\u0022{anchor.Id:N}\\t{anchor.GeneratedAttachmentName}\u0022 ) );\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\u0026\u0026 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 = \u0022attachment_name\u0022,\n\t\t\tFixedHeight = 27,\n\t\t\tToolTip = \u0022Attachment name written into the generated prefab and model\u0022\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 \u002B= () =\u003E RenameCustomAnchor( id, edit.Text );\n\t\tedit.ReturnPressed \u002B= () =\u003E RenameCustomAnchor( id, edit.Text );\n\t\trow.Layout.Add( edit, 1 );\n\n\t\tvar select = WeaponAnimatorTheme.Button(\n\t\t\t\u0022\u0022,\n\t\t\t\u0022ads_click\u0022,\n\t\t\t() =\u003E\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 = \u0022Select this anchor and show its gizmo\u0022;\n\t\t_customAnchorButtons[id] = select;\n\t\trow.Layout.Add( select );\n\n\t\tvar place = WeaponAnimatorTheme.Button(\n\t\t\t\u0022\u0022,\n\t\t\t\u0022my_location\u0022,\n\t\t\t() =\u003E PickRequested?.Invoke( ViewportPickMode.CustomAnchor, id ),\n\t\t\trow );\n\t\tplace.FixedWidth = 34;\n\t\tplace.ToolTip = \u0022Click the weapon surface to place this anchor\u0022;\n\t\trow.Layout.Add( place );\n\n\t\tvar remove = WeaponAnimatorTheme.Button(\n\t\t\t\u0022\u0022,\n\t\t\t\u0022delete\u0022,\n\t\t\t() =\u003E DeleteCustomAnchor( id ),\n\t\t\trow );\n\t\tremove.FixedWidth = 34;\n\t\tremove.ToolTip = \u0022Delete this anchor\u0022;\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( \u0022Add custom anchor\u0022, document =\u003E\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 = \u0022attachment\u0022,\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tprivate void RenameCustomAnchor( Guid id, string value )\n\t{\n\t\t_controller.Mutate( \u0022Rename custom anchor\u0022, document =\u003E\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 = \u0022anchor\u0022;\n\t\t\tif ( anchor.GeneratedAttachmentName == slug \u0026\u0026 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( \u0022Delete custom anchor\u0022, document =\u003E\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 = \u0022\u0022;\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( \u0022Clear all anchors\u0022, document =\u003E\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 = \u0022\u0022;\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\u003Cbool\u003E current,\n\t\tAction\u003Cbool\u003E 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 = () =\u003E changed( button.IsChecked );\n\t\t_documentRefreshers.Add( () =\u003E 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\u003Cstring\u003E current,\n\t\tSystem.Collections.Generic.IEnumerable\u003Cstring\u003E values,\n\t\tAction\u003Cstring\u003E changed )\n\t{\n\t\tvar button = new WeaponAnimatorButton( $\u0022{label}: {current()}\u0022, \u0022expand_more\u0022, parent )\n\t\t{\n\t\t\tTint = WeaponAnimatorTheme.SurfaceRaised\n\t\t};\n\t\tbutton.Clicked = () =\u003E\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, () =\u003E\n\t\t\t\t{\n\t\t\t\t\tchanged( captured );\n\t\t\t\t\tbutton.Text = $\u0022{label}: {current()}\u0022;\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( () =\u003E\n\t\t{\n\t\t\tbutton.Text = $\u0022{label}: {current()}\u0022;\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( \u0022Ready\u0022, 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 = \u0022\u0022 )\n\t{\n\t\tvar status = report.IsValid\n\t\t\t? report.WarningCount == 0 ? \u0022READY\u0022 : $\u0022{report.WarningCount} WARNING(S)\u0022\n\t\t\t: $\u0022{report.ErrorCount} ERROR(S)\u0022;\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? $\u0022{status} \u00B7 {string.Join( \u0022  \u00B7  \u0022, report.Issues.Take( 4 ).Select( x =\u003E x.Message ) )}\u0022\n\t\t\t: $\u0022{prefix} \u00B7 {status} \u00B7 {string.Join( \u0022  \u00B7  \u0022, report.Issues.Take( 4 ).Select( x =\u003E x.Message ) )}\u0022;\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 =\u003E WeaponAnimatorTheme.Coral,\n\t\t\tValidationSeverity.Warning =\u003E WeaponAnimatorTheme.Amber,\n\t\t\t_ =\u003E 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 ) =\u003E\n\t\t!float.IsNaN( value ) \u0026\u0026 !float.IsInfinity( value );\n\n\tpublic static bool IsFinite( Vector3 value ) =\u003E\n\t\tIsFinite( value.x ) \u0026\u0026 IsFinite( value.y ) \u0026\u0026 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 \u003C= Epsilon || knownInches \u003C= Epsilon )\n\t\t\treturn false;\n\n\t\tvar scale = knownInches / measuredUnits;\n\t\tif ( !IsFinite( scale ) || scale \u003C= 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 \u003C= 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 \u003C= 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 \u003C= Epsilon )\n\t\t\tprojectedUp = MathF.Abs( Vector3.Dot( forward, Vector3.Up ) ) \u003C 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 ) \u003C -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 \u003C= keys[0].Time )\n\t\t\treturn KeyTransform( keys[0] );\n\t\tif ( time \u003E= 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 \u003C high )\n\t\t{\n\t\t\tvar middle = low \u002B (high - low) / 2;\n\t\t\tif ( keys[middle].Time \u003C time )\n\t\t\t\tlow = middle \u002B 1;\n\t\t\telse\n\t\t\t\thigh = middle;\n\t\t}\n\n\t\tif ( MathF.Abs( keys[low].Time - time ) \u003C= 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 \u0026\u0026 !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 \u003C= 0 )\n\t\t\treturn 0;\n\t\tif ( fraction \u003E= 1 )\n\t\t\treturn 1;\n\n\t\tvar total = IntegrateMotionRate( curve, 1.0f );\n\t\tif ( total \u003C= 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 ) =\u003E\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 \u003C= 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 =\u003E MathF.Abs( x.Time - time ) \u003C= 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 ) =\u003E 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 =\u003E x.Time ).ToArray();\n\t\tvar adjacent = ordered\n\t\t\t.Zip( ordered.Skip( 1 ), ( start, end ) =\u003E (start.Id, end.Id) )\n\t\t\t.ToHashSet();\n\t\ttrack.CurveSpans.RemoveAll( span =\u003E\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 =\u003E (x.StartKeyId, x.EndKeyId) )\n\t\t\t.SelectMany( x =\u003E 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 \u003E Epsilon ? targetVector.Normal : Vector3.Forward;\n\t\tvar minimum = MathF.Abs( upperLength - lowerLength ) \u002B Epsilon;\n\t\tvar maximum = MathF.Max( upperLength \u002B lowerLength - Epsilon, minimum );\n\t\tvar solvedDistance = Math.Clamp( requestedDistance, minimum, maximum );\n\t\tvar reachable = requestedDistance \u003E= minimum \u0026\u0026 requestedDistance \u003C= maximum \u002B Epsilon;\n\t\tvar solvedEnd = root \u002B direction * solvedDistance;\n\n\t\tvar poleVector = pole - root;\n\t\tvar poleDirection = poleVector - direction * Vector3.Dot( poleVector, direction );\n\t\tif ( poleDirection.Length \u003C= Epsilon )\n\t\t{\n\t\t\tvar fallback = MathF.Abs( Vector3.Dot( direction, Vector3.Up ) ) \u003C 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\u002B solvedDistance * solvedDistance ) / (2.0f * solvedDistance);\n\t\tvar heightSquared = MathF.Max( upperLength * upperLength - along * along, 0 );\n\t\tvar elbow = root \u002B direction * along \u002B 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 \u003C= Epsilon || to.Length \u003C= 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 \u003C= Epsilon )\n\t\t{\n\t\t\tif ( dot \u003E= 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 \u003C= 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 ) =\u003E sboxUnits * CentimetresPerInch;\n\n\tpublic static Vector3 AxisVector( WeaponUpAxis axis ) =\u003E axis switch\n\t{\n\t\tWeaponUpAxis.NegativeZ =\u003E Vector3.Down,\n\t\tWeaponUpAxis.PositiveY =\u003E Vector3.Left,\n\t\tWeaponUpAxis.NegativeY =\u003E Vector3.Right,\n\t\t_ =\u003E Vector3.Up\n\t};\n\n\tprivate static Transform KeyTransform( TransformKey key ) =\u003E\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 \u003C= 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 \u003C wholeSteps; index\u002B\u002B )\n\t\t{\n\t\t\tvar start = index * step;\n\t\t\tvar finish = (index \u002B 1) * step;\n\t\t\tarea \u002B= (SampleMotionRate( curve, start ) \u002B 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 \u003C end )\n\t\t{\n\t\t\tarea \u002B= (SampleMotionRate( curve, remainderStart ) \u002B 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 \u0026 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 \u0026 (TransformCurveChannel)((int)firstChannel \u003C\u003C 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 \u0026 (TransformCurveChannel)((int)firstChannel \u003C\u003C 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 ) =\u003E\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 \u0026 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 \u0026 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 \u0026 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 \u0026 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 \u002B 1) * start\n\t\t\t\u002B (amount3 - 2 * amount2 \u002B amount) * startTangent\n\t\t\t\u002B (-2 * amount3 \u002B 3 * amount2) * end\n\t\t\t\u002B (amount3 - amount2) * endTangent;\n\t}\n\n\tprivate static float SmoothStep( float amount ) =\u003E\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 \u003E 180 )\n\t\t\tdifference -= 360;\n\t\telse if ( difference \u003C -180 )\n\t\t\tdifference \u002B= 360;\n\t\treturn reference \u002B 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 \u003C constraint.StartTime || time \u003E constraint.EndTime || constraint.Weight \u003C= 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 =\u003E x.Name, StringComparer.OrdinalIgnoreCase )\n\t\t\t.ToDictionary( x =\u003E x.Key, x =\u003E x.First(), StringComparer.OrdinalIgnoreCase );\n\t\tvar paths = new Dictionary\u003Cstring, string\u003E( StringComparer.OrdinalIgnoreCase );\n\n\t\tstring ResolvePath( WeaponBoneDefinition bone, HashSet\u003Cstring\u003E 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\u0026\u0026 byName.TryGetValue( bone.ParentName, out var parent ) )\n\t\t\t{\n\t\t\t\town = $\u0022{ResolvePath( parent, visiting )}/{own}\u0022;\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 ?? \u0022\u0022;\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 =\u003E string.IsNullOrWhiteSpace( x.ParentId ) );\n\t\trig.SourceSkeletonRootId = sourceRoot?.Id ?? \u0022\u0022;\n\t\tvar weaponRoot = rig.Bones.FirstOrDefault( x =\u003E\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 =\u003E 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 =\u003E 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 ) =\u003E\n\t\tbone.Inclusion != WeaponBoneInclusion.Excluded\n\t\t\u0026\u0026 bone.Classification != WeaponBoneClassification.Ignored;\n\n\tpublic static string ProfileText( WeaponRigDefinition rig ) =\u003E string.Join(\n\t\t\u0022\\n\u0022,\n\t\trig.Bones\n\t\t\t.OrderBy( x =\u003E x.Id, StringComparer.OrdinalIgnoreCase )\n\t\t\t.Select( x =\u003E\n\t\t\t\t$\u0022{x.Id}|{x.ParentId}|{x.Name}|{x.Classification}|{x.Inclusion}|\u0022\n\t\t\t\t\u002B $\u0022{x.BindModelTransform}|{x.BindLocalTransform}\u0022 ) );\n\n\tprivate static HashSet\u003Cstring\u003E DescendantIds( WeaponRigDefinition rig, string rootId )\n\t{\n\t\tvar result = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tvar pending = new Queue\u003Cstring\u003E();\n\t\tpending.Enqueue( rootId );\n\t\twhile ( pending.Count \u003E 0 )\n\t\t{\n\t\t\tvar parentId = pending.Dequeue();\n\t\t\tforeach ( var child in rig.Bones.Where( x =\u003E\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\u003Cstring\u003E AncestorIds(\n\t\tWeaponRigDefinition rig,\n\t\tWeaponBoneDefinition bone )\n\t{\n\t\tvar result = new HashSet\u003Cstring\u003E( StringComparer.OrdinalIgnoreCase );\n\t\tvar parentId = bone.ParentId;\n\t\twhile ( !string.IsNullOrWhiteSpace( parentId ) \u0026\u0026 result.Add( parentId ) )\n\t\t\tparentId = rig.FindBone( parentId )?.ParentId ?? \u0022\u0022;\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 ) =\u003E\n\t\tvalue.Replace( \u0022%\u0022, \u0022%25\u0022, StringComparison.Ordinal )\n\t\t\t.Replace( \u0022/\u0022, \u0022%2F\u0022, 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( \u0022AddonTitle\u0022, \u0022S\u0026box Weapon Animator\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022AddonIdent\u0022, \u0022sbox-animator\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022OrgIdent\u0022, \u0022sonac\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022Ident\u0022, \u0022sonac.sbox-animator\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineVersion\u0022, \u002228\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022EngineMinorVersion\u0022, \u00221\u0022 )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \u0022.NETCoreApp,Version=v9.0\u0022, FrameworkDisplayName = \u0022.NET 9.0\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \u0022CompileTime\u0022, \u00222026-07-29T19:18:21.4795109Z\u0022 )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\u00220.0.115.0\u0022)]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\u00220.0.115.0\u0022)]"},{"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 ) =\u003E\n\t\t!float.IsNaN( value ) \u0026\u0026 !float.IsInfinity( value );\n\n\tpublic static bool IsFinite( Vector3 value ) =\u003E\n\t\tIsFinite( value.x ) \u0026\u0026 IsFinite( value.y ) \u0026\u0026 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 \u003C= Epsilon || knownInches \u003C= Epsilon )\n\t\t\treturn false;\n\n\t\tvar scale = knownInches / measuredUnits;\n\t\tif ( !IsFinite( scale ) || scale \u003C= 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 \u003C= 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 \u003C= 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 \u003C= Epsilon )\n\t\t\tprojectedUp = MathF.Abs( Vector3.Dot( forward, Vector3.Up ) ) \u003C 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 ) \u003C -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 \u003C= keys[0].Time )\n\t\t\treturn KeyTransform( keys[0] );\n\t\tif ( time \u003E= 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 \u003C high )\n\t\t{\n\t\t\tvar middle = low \u002B (high - low) / 2;\n\t\t\tif ( keys[middle].Time \u003C time )\n\t\t\t\tlow = middle \u002B 1;\n\t\t\telse\n\t\t\t\thigh = middle;\n\t\t}\n\n\t\tif ( MathF.Abs( keys[low].Time - time ) \u003C= 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 \u0026\u0026 !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 \u003C= 0 )\n\t\t\treturn 0;\n\t\tif ( fraction \u003E= 1 )\n\t\t\treturn 1;\n\n\t\tvar total = IntegrateMotionRate( curve, 1.0f );\n\t\tif ( total \u003C= 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 ) =\u003E\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 \u003C= 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 =\u003E MathF.Abs( x.Time - time ) \u003C= 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 ) =\u003E 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 =\u003E x.Time ).ToArray();\n\t\tvar adjacent = ordered\n\t\t\t.Zip( ordered.Skip( 1 ), ( start, end ) =\u003E (start.Id, end.Id) )\n\t\t\t.ToHashSet();\n\t\ttrack.CurveSpans.RemoveAll( span =\u003E\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 =\u003E (x.StartKeyId, x.EndKeyId) )\n\t\t\t.SelectMany( x =\u003E 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 \u003E Epsilon ? targetVector.Normal : Vector3.Forward;\n\t\tvar minimum = MathF.Abs( upperLength - lowerLength ) \u002B Epsilon;\n\t\tvar maximum = MathF.Max( upperLength \u002B lowerLength - Epsilon, minimum );\n\t\tvar solvedDistance = Math.Clamp( requestedDistance, minimum, maximum );\n\t\tvar reachable = requestedDistance \u003E= minimum \u0026\u0026 requestedDistance \u003C= maximum \u002B Epsilon;\n\t\tvar solvedEnd = root \u002B direction * solvedDistance;\n\n\t\tvar poleVector = pole - root;\n\t\tvar poleDirection = poleVector - direction * Vector3.Dot( poleVector, direction );\n\t\tif ( poleDirection.Length \u003C= Epsilon )\n\t\t{\n\t\t\tvar fallback = MathF.Abs( Vector3.Dot( direction, Vector3.Up ) ) \u003C 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\u002B solvedDistance * solvedDistance ) / (2.0f * solvedDistance);\n\t\tvar heightSquared = MathF.Max( upperLength * upperLength - along * along, 0 );\n\t\tvar elbow = root \u002B direction * along \u002B 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 \u003C= Epsilon || to.Length \u003C= 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 \u003C= Epsilon )\n\t\t{\n\t\t\tif ( dot \u003E= 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 \u003C= 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 ) =\u003E sboxUnits * CentimetresPerInch;\n\n\tpublic static Vector3 AxisVector( WeaponUpAxis axis ) =\u003E axis switch\n\t{\n\t\tWeaponUpAxis.NegativeZ =\u003E Vector3.Down,\n\t\tWeaponUpAxis.PositiveY =\u003E Vector3.Left,\n\t\tWeaponUpAxis.NegativeY =\u003E Vector3.Right,\n\t\t_ =\u003E Vector3.Up\n\t};\n\n\tprivate static Transform KeyTransform( TransformKey key ) =\u003E\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 \u003C= 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 \u003C wholeSteps; index\u002B\u002B )\n\t\t{\n\t\t\tvar start = index * step;\n\t\t\tvar finish = (index \u002B 1) * step;\n\t\t\tarea \u002B= (SampleMotionRate( curve, start ) \u002B 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 \u003C end )\n\t\t{\n\t\t\tarea \u002B= (SampleMotionRate( curve, remainderStart ) \u002B 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 \u0026 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 \u0026 (TransformCurveChannel)((int)firstChannel \u003C\u003C 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 \u0026 (TransformCurveChannel)((int)firstChannel \u003C\u003C 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 ) =\u003E\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 \u0026 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 \u0026 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 \u0026 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 \u0026 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 \u002B 1) * start\n\t\t\t\u002B (amount3 - 2 * amount2 \u002B amount) * startTangent\n\t\t\t\u002B (-2 * amount3 \u002B 3 * amount2) * end\n\t\t\t\u002B (amount3 - amount2) * endTangent;\n\t}\n\n\tprivate static float SmoothStep( float amount ) =\u003E\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 \u003E 180 )\n\t\t\tdifference -= 360;\n\t\telse if ( difference \u003C -180 )\n\t\t\tdifference \u002B= 360;\n\t\treturn reference \u002B 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 \u003C constraint.StartTime || time \u003E constraint.EndTime || constraint.Weight \u003C= 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 \u003C\u003C 0,\n\tPositionY = 1 \u003C\u003C 1,\n\tPositionZ = 1 \u003C\u003C 2,\n\tRotationX = 1 \u003C\u003C 3,\n\tRotationY = 1 \u003C\u003C 4,\n\tRotationZ = 1 \u003C\u003C 5,\n\tScaleX = 1 \u003C\u003C 6,\n\tScaleY = 1 \u003C\u003C 7,\n\tScaleZ = 1 \u003C\u003C 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; } = \u0022New Weapon\u0022;\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\u003CWeaponAnimationClip\u003E 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 = \u0022New Weapon\u0022 )\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 =\u003E WeaponAnimationClip.Create( role ) )\n\t\t\t.ToList();\n\n\t\tdocument.Workspace.SelectedClipId = document.Clips\n\t\t\t.First( x =\u003E 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 =\u003E 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 =\u003E 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\u003CWeaponClipRole\u003E StandardClips() =\u003E\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 \u0022weapon\u0022;\n\n\t\tvar chars = value.Trim().ToLowerInvariant()\n\t\t\t.Select( c =\u003E char.IsLetterOrDigit( c ) ? c : \u0027_\u0027 )\n\t\t\t.ToArray();\n\n\t\treturn string.Join( \u0022_\u0022, new string( chars )\n\t\t\t.Split( \u0027_\u0027, StringSplitOptions.RemoveEmptyEntries ) );\n\t}\n}\n\npublic sealed class SourceModelSettings\n{\n\tpublic string OriginalSourcePath { get; set; } = \u0022\u0022;\n\tpublic string SourcePath { get; set; } = \u0022\u0022;\n\tpublic string CompiledModelPath { get; set; } = \u0022\u0022;\n\tpublic string PreviewHostPath { get; set; } = \u0022\u0022;\n\tpublic string SourceHash { get; set; } = \u0022\u0022;\n\tpublic string SourceRootBoneName { get; set; } = \u0022\u0022;\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\u003CSourceMaterialBinding\u003E 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; } = \u0022\u0022;\n\tpublic string Name { get; set; } = \u0022\u0022;\n\tpublic string OutputName { get; set; } = \u0022\u0022;\n\n\t[JsonIgnore]\n\tpublic string PreviewMaterialPath { get; set; } = \u0022\u0022;\n\tpublic List\u003CSourceTextureMap\u003E Textures { get; set; } = [];\n\n\tpublic SourceTextureMap? FindTexture( WeaponTextureChannel channel ) =\u003E\n\t\tTextures.FirstOrDefault( texture =\u003E texture.Channel == channel );\n\n\tpublic bool HasUsableTextures =\u003E\n\t\tTextures.Any( texture =\u003E texture.Channel != WeaponTextureChannel.PackedOrm\n\t\t\t\u0026\u0026 !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; } = \u0022\u0022;\n\tpublic string AssetPath { get; set; } = \u0022\u0022;\n\tpublic string Sha256 { get; set; } = \u0022\u0022;\n}\n\npublic sealed class WeaponRigDefinition\n{\n\tpublic string RootBone { get; set; } = \u0022\u0022;\n\tpublic string SourceSkeletonRootId { get; set; } = \u0022\u0022;\n\tpublic string WeaponSubtreeRootId { get; set; } = \u0022\u0022;\n\tpublic List\u003CWeaponBoneDefinition\u003E Bones { get; set; } = [];\n\tpublic List\u003CWeaponVisibilityPart\u003E VisibilityParts { get; set; } = [];\n\tpublic List\u003CRigAuditIssue\u003E AuditIssues { get; set; } = [];\n\tpublic string ProfileHash { get; set; } = \u0022\u0022;\n\tpublic bool ReviewRequired { get; set; }\n\tpublic bool FilteredPreviewConfirmed { get; set; }\n\n\tpublic WeaponBoneDefinition? FindBone( string idOrName ) =\u003E\n\t\tBones.FirstOrDefault( x =\u003E\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\u003CWeaponBoneDefinition\u003E RetainedBones() =\u003E\n\t\tBones.Where( x =\u003E x.Inclusion != WeaponBoneInclusion.Excluded\n\t\t\t\u0026\u0026 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; } = \u0022Visible Part\u0022;\n\tpublic string BoneId { get; set; } = \u0022\u0022;\n\tpublic string BoneName { get; set; } = \u0022\u0022;\n\tpublic bool DefaultVisible { get; set; } = true;\n\tpublic VisibilityRenderMode RenderMode { get; set; } = VisibilityRenderMode.BoneBranch;\n\tpublic string BodyGroupName { get; set; } = \u0022\u0022;\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; } = \u0022\u0022;\n\tpublic string ParentId { get; set; } = \u0022\u0022;\n\tpublic string HierarchyPath { get; set; } = \u0022\u0022;\n\tpublic string Name { get; set; } = \u0022\u0022;\n\tpublic string ParentName { get; set; } = \u0022\u0022;\n\tpublic string OriginalName { get; set; } = \u0022\u0022;\n\tpublic string OriginalParentName { get; set; } = \u0022\u0022;\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; } = \u0022\u0022;\n\tpublic string Message { get; set; } = \u0022\u0022;\n\tpublic ValidationSeverity Severity { get; set; } = ValidationSeverity.Warning;\n\tpublic string BoneName { get; set; } = \u0022\u0022;\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\u003CWeaponAnchor\u003E 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; } = \u002216:9\u0022;\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/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tpublic WeaponAnchor? GetAnchor( AnchorKind kind ) =\u003E\n\t\tAnchors.FirstOrDefault( x =\u003E x.Kind == kind );\n\n\tpublic WeaponAnchor? FindAnchor( Guid id ) =\u003E\n\t\tAnchors.FirstOrDefault( x =\u003E x.Id == id );\n\n\tpublic IEnumerable\u003CWeaponAnchor\u003E CustomAnchors() =\u003E\n\t\tAnchors.Where( x =\u003E 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; } = \u0022\u0022;\n\tpublic string SecondBone { get; set; } = \u0022\u0022;\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; } = \u0022\u0022;\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tpublic string GeneratedAttachmentName { get; set; } = \u0022\u0022;\n\tpublic AnchorKind Kind { get; set; }\n\tpublic string BoneName { get; set; } = \u0022\u0022;\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; } = \u0022\u0022;\n\tpublic string RigHash { get; set; } = \u0022\u0022;\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\u003CWeaponAnchor\u003E Anchors { get; set; } = [];\n\tpublic DateTime ConfirmedUtc { get; set; }\n}\n\npublic sealed class ArmBindingDefinition\n{\n\tpublic string Profile { get; set; } = \u0022FacepunchHumanV1\u0022;\n\tpublic string ArmsModel { get; set; } = \u0022models/first_person/v_first_person_arms_human.vmdl\u0022;\n\tpublic GripConfiguration Configuration { get; set; } = GripConfiguration.TwoHanded;\n\tpublic RigTarget PrimaryHand { get; set; } = RigTarget.Create( \u0022Primary Hand\u0022, true );\n\tpublic RigTarget SupportHand { get; set; } = RigTarget.Create( \u0022Support Hand\u0022, false );\n\tpublic RigTarget PrimaryElbowPole { get; set; } = RigTarget.CreatePole( \u0022Primary Elbow\u0022 );\n\tpublic RigTarget SupportElbowPole { get; set; } = RigTarget.CreatePole( \u0022Support Elbow\u0022 );\n\tpublic List\u003CGripPose\u003E GripPoses { get; set; } = [];\n\tpublic Guid DefaultGripPoseId { get; set; }\n\tpublic bool ChecklistDismissed { get; set; }\n\tpublic List\u003Cstring\u003E CompletedChecklistItems { get; set; } = [];\n}\n\npublic sealed class RigTarget\n{\n\tpublic Guid Id { get; set; } = Guid.NewGuid();\n\tpublic string Name { get; set; } = \u0022\u0022;\n\tpublic RigControlKind Kind { get; set; } = RigControlKind.Arm;\n\tpublic Transform Transform { get; set; } = Transform.Zero;\n\tpublic string AttachedBone { get; set; } = \u0022\u0022;\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 ) =\u003E 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 ) =\u003E new()\n\t{\n\t\tName = name,\n\t\tTransform = new Transform( new Vector3( 5, name.Contains( \u0022Primary\u0022 ) ? -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; } = \u0022Default Grip\u0022;\n\tpublic List\u003CBonePose\u003E Bones { get; set; } = [];\n}\n\npublic sealed class BonePose\n{\n\tpublic string BoneName { get; set; } = \u0022\u0022;\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; } = \u0022Custom\u0022;\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; } = \u0022\u0022;\n\tpublic List\u003CTransformTrack\u003E Tracks { get; set; } = [];\n\tpublic List\u003CVisibilityTrack\u003E VisibilityTracks { get; set; } = [];\n\tpublic List\u003CTimedConstraint\u003E Constraints { get; set; } = [];\n\tpublic List\u003CAnimationTag\u003E Tags { get; set; } = [];\n\tpublic List\u003CClipParameterEvent\u003E ParameterEvents { get; set; } = [];\n\tpublic string ImportedSequence { get; set; } = \u0022\u0022;\n\n\tpublic static WeaponAnimationClip Create( WeaponClipRole role ) =\u003E 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 =\u003E 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 =\u003E 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\u003CVisibilityKey\u003E 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; } = \u0022\u0022;\n\tpublic RigControlKind Kind { get; set; } = RigControlKind.Weapon;\n\tpublic TrackInterpolation Interpolation { get; set; } = TrackInterpolation.Cubic;\n\tpublic List\u003CTransformKey\u003E Keys { get; set; } = [];\n\tpublic List\u003CTransformCurveSpan\u003E CurveSpans { get; set; } = [];\n\tpublic bool Muted { get; set; }\n\n\tpublic TransformCurveSpan? FindCurveSpan( Guid startKeyId, Guid endKeyId ) =\u003E\n\t\tCurveSpans.FirstOrDefault( x =\u003E\n\t\t\tx.StartKeyId == startKeyId \u0026\u0026 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; } = \u0022\u0022;\n\tpublic string TargetBone { get; set; } = \u0022\u0022;\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; } = \u0022\u0022;\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; } = \u0022\u0022;\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; } = \u0022FacepunchHumanV1\u0022;\n\tpublic ReloadProfile ReloadProfile { get; set; } = ReloadProfile.Magazine;\n\tpublic bool FirearmProfile { get; set; } = true;\n\tpublic Dictionary\u003Cstring, float\u003E PreviewFloats { get; set; } = [];\n\tpublic Dictionary\u003Cstring, bool\u003E PreviewBools { get; set; } = [];\n}\n\npublic sealed class OutputSettings\n{\n\tpublic string AssetName { get; set; } = \u0022weapon\u0022;\n\tpublic string OutputFolder { get; set; } = \u0022\u0022;\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 $\u0022weapons/{slug}/viewmodel\u0022;\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; } = \u0022\u0022;\n\tpublic string SelectedControl { get; set; } = \u0022\u0022;\n\tpublic string ConstraintTargetBone { get; set; } = \u0022\u0022;\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\u003CWorkingPoseOverride\u003E WorkingPoseOverrides { get; set; } = [];\n\tpublic List\u003CTimelineViewState\u003E TimelineViews { get; set; } = [];\n\tpublic List\u003CCurveViewState\u003E 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; } = \u0022\u0022;\n\tpublic string AnimationSplitterState { get; set; } = \u0022\u0022;\n\tpublic string CalibrationVerticalSplitterState { get; set; } = \u0022\u0022;\n\tpublic string AnimationVerticalSplitterState { get; set; } = \u0022\u0022;\n\tpublic string AnimationTimelineSplitterState { get; set; } = \u0022\u0022;\n\tpublic string AnimationRightSplitterState { get; set; } = \u0022\u0022;\n\tpublic string AnimationMainSplitterState { get; set; } = \u0022\u0022;\n\tpublic string AnimationOuterSplitterState { get; set; } = \u0022\u0022;\n\n\tpublic TimelineViewState? GetTimelineView( Guid clipId ) =\u003E\n\t\tTimelineViews.FirstOrDefault( x =\u003E 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 ) =\u003E\n\t\tCurveViews.FirstOrDefault( x =\u003E 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 ) =\u003E\n\t\tWorkingPoseOverrides.FirstOrDefault( x =\u003E\n\t\t\tx.ClipId == clipId\n\t\t\t\u0026\u0026 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 ) =\u003E\n\t\tWorkingPoseOverrides.RemoveAll( x =\u003E\n\t\t\tx.ClipId == clipId\n\t\t\t\u0026\u0026 x.Target.Equals( target, StringComparison.OrdinalIgnoreCase ) ) \u003E 0;\n\n\tpublic void ClearWorkingPoses( Guid clipId ) =\u003E\n\t\tWorkingPoseOverrides.RemoveAll( x =\u003E 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; } = \u0022\u0022;\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; } = \u0022\u0022;\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; } = \u0022\u0022;\n\tpublic DateTime GeneratedUtc { get; set; }\n\tpublic string InputHash { get; set; } = \u0022\u0022;\n\tpublic List\u003CGeneratedFileRecord\u003E Files { get; set; } = [];\n\tpublic List\u003CGenerationDiagnostic\u003E Diagnostics { get; set; } = [];\n}\n\npublic sealed class GeneratedFileRecord\n{\n\tpublic string RelativePath { get; set; } = \u0022\u0022;\n\tpublic string Sha256 { get; set; } = \u0022\u0022;\n\tpublic string Kind { get; set; } = \u0022\u0022;\n}\n\npublic sealed class GenerationDiagnostic\n{\n\tpublic ValidationSeverity Severity { get; set; }\n\tpublic string Code { get; set; } = \u0022\u0022;\n\tpublic string Message { get; set; } = \u0022\u0022;\n\tpublic string AssetPath { get; set; } = \u0022\u0022;\n}\n\npublic static class WeaponAnimationNames\n{\n\tpublic static string DisplayName( WeaponClipRole role ) =\u003E role switch\n\t{\n\t\tWeaponClipRole.FireDry =\u003E \u0022Fire Dry\u0022,\n\t\tWeaponClipRole.ReloadEmpty =\u003E \u0022Reload Empty\u0022,\n\t\tWeaponClipRole.GrabStance =\u003E \u0022Grab Stance\u0022,\n\t\tWeaponClipRole.GrabGestureOne =\u003E \u0022Grab Gesture 1\u0022,\n\t\tWeaponClipRole.GrabGestureTwo =\u003E \u0022Grab Gesture 2\u0022,\n\t\tWeaponClipRole.GrabGestureThree =\u003E \u0022Grab Gesture 3\u0022,\n\t\tWeaponClipRole.GrabGestureFour =\u003E \u0022Grab Gesture 4\u0022,\n\t\tWeaponClipRole.ReloadEnter =\u003E \u0022Reload Enter\u0022,\n\t\tWeaponClipRole.FirstShell =\u003E \u0022First Shell\u0022,\n\t\tWeaponClipRole.InsertShell =\u003E \u0022Insert Shell\u0022,\n\t\tWeaponClipRole.ReloadExit =\u003E \u0022Reload Exit\u0022,\n\t\t_ =\u003E role.ToString()\n\t};\n\n\tpublic static string SequenceName( WeaponClipRole role ) =\u003E\n\t\tWeaponAnimationDocument.Slugify( DisplayName( role ) );\n\n\tpublic static string SequenceName( WeaponAnimationClip clip ) =\u003E\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 =\u003E clip.Role != WeaponClipRole.Custom )\n\t\t\t.Select( clip =\u003E SequenceName( clip.Role ) )\n\t\t\t.ToHashSet( StringComparer.OrdinalIgnoreCase );\n\t\tforeach ( var clip in document.Clips.Where( clip =\u003E\n\t\t\tclip.Role == WeaponClipRole.Custom ) )\n\t\t{\n\t\t\tvar existing = string.IsNullOrWhiteSpace( clip.GeneratedSequenceName )\n\t\t\t\t? \u0022\u0022\n\t\t\t\t: WeaponAnimationDocument.Slugify( clip.GeneratedSequenceName );\n\t\t\tif ( !string.IsNullOrWhiteSpace( existing ) \u0026\u0026 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 = \u0022custom\u0022;\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( \u0022N\u0022 );\n\t\t\t\tvar assigned = false;\n\t\t\t\tfor ( var suffixLength = 8;\n\t\t\t\t\tsuffixLength \u003C= id.Length;\n\t\t\t\t\tsuffixLength \u002B= 4 )\n\t\t\t\t{\n\t\t\t\t\tcandidate = $\u0022{stem}_{id[..suffixLength]}\u0022;\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\u002B\u002B )\n\t\t\t\t{\n\t\t\t\t\tcandidate = $\u0022{stem}_{id}_{collision}\u0022;\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 ) =\u003E\n\t\t$\u0022{stem}_{id:N}\u0022[..(stem.Length \u002B 9)];\n\n\t/// \u003Csummary\u003E\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/// \u003C/summary\u003E\n\tprivate static readonly string[] ReservedAttachmentNames = [\u0022muzzle\u0022, \u0022eject\u0022];\n\n\tpublic static string AttachmentName( WeaponAnchor anchor ) =\u003E anchor.Kind switch\n\t{\n\t\tAnchorKind.Muzzle =\u003E \u0022muzzle\u0022,\n\t\tAnchorKind.Eject =\u003E \u0022eject\u0022,\n\t\t_ =\u003E !string.IsNullOrWhiteSpace( anchor.GeneratedAttachmentName )\n\t\t\t? anchor.GeneratedAttachmentName\n\t\t\t: WeaponAnimationDocument.Slugify( anchor.Name )\n\t};\n\n\t/// \u003Csummary\u003E\n\t/// Assigns each custom anchor a stable, unique attachment name. Mirrors\n\t/// \u003Csee cref=\u0022RepairCustomSequenceNames\u0022/\u003E: an existing stored name is kept whenever it is still\n\t/// unique, so generated output stays stable across renames.\n\t/// \u003C/summary\u003E\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? \u0022\u0022\n\t\t\t\t: WeaponAnimationDocument.Slugify( anchor.GeneratedAttachmentName );\n\t\t\tif ( !string.IsNullOrWhiteSpace( existing ) \u0026\u0026 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 = \u0022anchor\u0022;\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( \u0022N\u0022 );\n\t\t\t\tvar assigned = false;\n\t\t\t\tfor ( var suffixLength = 8; suffixLength \u003C= id.Length; suffixLength \u002B= 4 )\n\t\t\t\t{\n\t\t\t\t\tcandidate = $\u0022{stem}_{id[..suffixLength]}\u0022;\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\u002B\u002B )\n\t\t\t\t{\n\t\t\t\t\tcandidate = $\u0022{stem}_{id}_{collision}\u0022;\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; } = \u0022\u0022;\n\tpublic string ModelPath { get; init; } = \u0022\u0022;\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 =\u003E\n\t\t\t\tx.Code is \u0022arm_bone_collision\u0022 or \u0022bind_pose_mismatch\u0022 );\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 = \u0022arm_bone_collision\u0022,\n\t\t\t\t\tMessage = $\u0022Retained weapon bone \u0027{collision}\u0027 conflicts with the Facepunch arm skeleton.\u0022,\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 = \u0022bind_pose_mismatch\u0022,\n\t\t\t\t\tMessage =\n\t\t\t\t\t\t$\u0022Bind pose mismatch for \u0027{mismatch.BoneName}\u0027: \u0022\n\t\t\t\t\t\t\u002B $\u0022position {mismatch.PositionDelta:0.######}, \u0022\n\t\t\t\t\t\t\u002B $\u0022rotation {mismatch.RotationDelta:0.######}, \u0022\n\t\t\t\t\t\t\u002B $\u0022scale {mismatch.ScaleDelta:0.######}.\u0022,\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 \u003E 0 || parityIssues.Count \u003E 0 )\n\t\t\t{\n\t\t\t\tdocument.Source.PreviewHostCompiled = false;\n\t\t\t\tvar blockedDetail = collisions.Count \u003E 0\n\t\t\t\t\t? $\u0022Retained weapon bones collide with Facepunch bones: {string.Join( \u0022, \u0022, collisions )}.\u0022\n\t\t\t\t\t: document.Rig.AuditIssues.First( x =\u003E x.Code == \u0022bind_pose_mismatch\u0022 ).Message;\n\t\t\t\tLog.Warning( $\u0022[Weapon Animator] preview host blocked: {blockedDetail}\u0022 );\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, \u0022animation_host_reference.dmx\u0022 );\n\t\t\tvar vmdlAbsolute = Path.Combine( cache, \u0022animation_host_preview.vmdl\u0022 );\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\u0022\u0022,\n\t\t\t\t\tskeleton.Bones.Select( bone =\u003E 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\u0026\u0026 asset.IsCompiled\n\t\t\t\t\u0026\u0026 asset.IsCompiledAndUpToDate;\n\t\t\tvar model = compiled ? asset!.LoadResource\u003CModel\u003E() : null;\n\t\t\tvar success = compiled \u0026\u0026 model is not null \u0026\u0026 !model.IsError \u0026\u0026 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 ?? \u0022\u0022;\n\t\t\tdocument.Source.PreviewHostCompiled = success;\n\t\t\tif ( !success )\n\t\t\t\tLog.Warning( $\u0022[Weapon Animator] preview host verification failed: {detail}\u0022 );\n\t\t\treturn new PreviewHostResult\n\t\t\t{\n\t\t\t\tSuccess = success,\n\t\t\t\tModelPath = asset?.Path ?? \u0022\u0022,\n\t\t\t\tMessage = success\n\t\t\t\t\t? $\u0022Preview host compiled with {skeleton.Bones.Count} bones.\u0022\n\t\t\t\t\t: $\u0022Preview host verification failed: {detail}\u0022\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( $\u0022[Weapon Animator] preview host build failed: {ex}\u0022 );\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 \u0022the generated VMDL was not registered with the Asset System.\u0022;\n\t\tif ( !compiled )\n\t\t{\n\t\t\treturn $\u0022compile returned {compileReturned}, IsCompiled={asset.IsCompiled}, \u0022\n\t\t\t\t\u002B $\u0022IsCompiledAndUpToDate={asset.IsCompiledAndUpToDate}.\u0022;\n\t\t}\n\t\tif ( model is null )\n\t\t\treturn \u0022the compiled resource could not be loaded as a model.\u0022;\n\t\tif ( model.IsError )\n\t\t\treturn \u0022the compiled resource reloaded as the error model.\u0022;\n\t\tif ( model.BoneCount != expectedBones )\n\t\t\treturn $\u0022expected {expectedBones} bones but the compiled model exposes {model.BoneCount}.\u0022;\n\t\treturn \u0022the compiled resource did not pass validation.\u0022;\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 \u002B $\u0022.tmp.{Guid.NewGuid():N}\u0022;\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\u003Cstring, WeaponAnimatorButton\u003E _itemButtons =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate readonly Dictionary\u003Cstring, bool\u003E _weaponItems =\n\t\tnew( StringComparer.OrdinalIgnoreCase );\n\tprivate readonly Dictionary\u003Cstring, bool\u003E _expanded = new( StringComparer.OrdinalIgnoreCase )\n\t{\n\t\t[\u0022Controls\u0022] = true,\n\t\t[\u0022Weapon\u0022] = true,\n\t\t[\u0022Right arm\u0022] = true,\n\t\t[\u0022Left arm\u0022] = true,\n\t\t[\u0022Fingers\u0022] = false,\n\t\t[\u0022Advanced\u0022] = false\n\t};\n\tprivate string _filter = \u0022\u0022;\n\tprivate bool _rebuilding;\n\tprivate bool _rebuildPending;\n\tprivate string _structureSignature = \u0022\u0022;\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 = \u0022Search controls and bones\u2026\u0022,\n\t\t\tFixedHeight = 28\n\t\t};\n\t\t_search.SetStyles( WeaponAnimatorTheme.InputStyle );\n\t\t_search.TextChanged \u002B= text =\u003E\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 \u002B= RefreshDocument;\n\t\t_controller.SelectionChanged \u002B= 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[] { \u0022Weapon\u0022, \u0022Right arm\u0022, \u0022Left arm\u0022, \u0022Fingers\u0022, \u0022Advanced\u0022 } )\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(\u0022@primary_hand\u0022, $\u0022Primary hand \u00B7 {BoundText( _controller.Document.Binding.PrimaryHand )}\u0022),\n\t\t\t(\u0022@support_hand\u0022, $\u0022Support hand \u00B7 {BoundText( _controller.Document.Binding.SupportHand )}\u0022),\n\t\t\t(\u0022@primary_elbow\u0022, \u0022Primary elbow\u0022),\n\t\t\t(\u0022@support_elbow\u0022, \u0022Support elbow\u0022)\n\t\t};\n\t\tvar visible = controls.Where( x =\u003E Matches( x.Item2 ) ).ToArray();\n\t\tvar body = AddGroup( \u0022Controls\u0022, 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 = () =\u003E _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\u003CHostBone\u003E bones,\n\t\tHostSkeleton skeleton )\n\t{\n\t\tvar visible = bones.Where( x =\u003E Matches( x.Name ) ).ToArray();\n\t\tvar body = AddGroup( name, visible.Length );\n\n\t\tforeach ( var bone in visible.OrderBy( x =\u003E 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 = () =\u003E _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 = () =\u003E\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 ) =\u003E\n\t\t_expanded[name] || !string.IsNullOrWhiteSpace( _filter );\n\n\tprivate string GroupHeaderText( string name, int count ) =\u003E\n\t\t$\u0022{(GroupBodyVisible( name ) ? \u0022\u25BE\u0022 : \u0022\u25B8\u0022)}  {name.ToUpperInvariant()}  {count}\u0022;\n\n\tprivate string SelectedGroup()\n\t{\n\t\tif ( !string.IsNullOrWhiteSpace( _controller.Document.Workspace.SelectedControl ) )\n\t\t\treturn \u0022Controls\u0022;\n\t\tvar selected = _controller.Document.Workspace.SelectedBone;\n\t\tif ( string.IsNullOrWhiteSpace( selected ) )\n\t\t\treturn \u0022\u0022;\n\t\treturn GroupName( HostSkeletonBuilder.BuildCached( _controller.Document ).ByName.GetValueOrDefault( selected ) );\n\t}\n\n\tprivate Dictionary\u003Cstring, List\u003CHostBone\u003E\u003E GroupBones( HostSkeleton skeleton )\n\t{\n\t\tvar groups = new Dictionary\u003Cstring, List\u003CHostBone\u003E\u003E( 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 \u0022Advanced\u0022;\n\t\tif ( bone.IsWeaponBone )\n\t\t\treturn \u0022Weapon\u0022;\n\t\tif ( bone.Name.Contains( \u0022finger\u0022, StringComparison.OrdinalIgnoreCase )\n\t\t\t|| bone.Name.Contains( \u0022thumb\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn \u0022Fingers\u0022;\n\t\tif ( bone.Name.EndsWith( \u0022_R\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn \u0022Right arm\u0022;\n\t\tif ( bone.Name.EndsWith( \u0022_L\u0022, StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn \u0022Left arm\u0022;\n\t\treturn \u0022Advanced\u0022;\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\u0026\u0026 skeleton.ByName.TryGetValue( parent, out var parentBone )\n\t\t\t\u0026\u0026 depth \u003C 16 )\n\t\t{\n\t\t\tdepth\u002B\u002B;\n\t\t\tparent = parentBone.ParentName;\n\t\t}\n\t\treturn depth;\n\t}\n\n\tprivate bool Matches( string text ) =\u003E\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\u0026\u0026 _expanded.TryGetValue( group, out var expanded )\n\t\t\t\u0026\u0026 !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\u0026\u0026 !string.IsNullOrWhiteSpace( selected )\n\t\t\t\u0026\u0026 _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 \u003C= 0 || _scroll.Height \u003C= 0 )\n\t\t\treturn;\n\n\t\tvar viewportTop = _scroll.ScreenPosition.y;\n\t\tvar viewportBottom = viewportTop \u002B _scroll.Height;\n\t\tvar itemTop = button.ScreenPosition.y;\n\t\tvar itemBottom = itemTop \u002B button.Height;\n\t\tif ( itemTop \u003C 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 \u003E viewportBottom )\n\t\t{\n\t\t\t_scroll.VerticalScrollbar.Value \u002B=\n\t\t\t\t(itemBottom - viewportBottom).CeilToInt();\n\t\t}\n\t}\n\n\tprivate string SelectedItem() =\u003E\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\u003Cstring, string\u003E( StringComparer.OrdinalIgnoreCase )\n\t\t{\n\t\t\t[\u0022@primary_hand\u0022] =\n\t\t\t\t$\u0022Primary hand \u00B7 {BoundText( _controller.Document.Binding.PrimaryHand )}\u0022,\n\t\t\t[\u0022@support_hand\u0022] =\n\t\t\t\t$\u0022Support hand \u00B7 {BoundText( _controller.Document.Binding.SupportHand )}\u0022,\n\t\t\t[\u0022@primary_elbow\u0022] = \u0022Primary elbow\u0022,\n\t\t\t[\u0022@support_elbow\u0022] = \u0022Support elbow\u0022\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 =\u003E 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? \u0022\u0022\n\t\t\t\t: \u0022visibility\u0022;\n\t\t}\n\t}\n\n\tinternal static string StructureSignature( HostSkeleton skeleton ) =\u003E\n\t\tstring.Join(\n\t\t\t\u0022|\u0022,\n\t\t\tskeleton.Bones.Select( bone =\u003E\n\t\t\t\t$\u0022{bone.Name}\u003E{bone.ParentName}:{bone.IsWeaponBone}\u0022 ) );\n\n\tprivate static string BoundText( RigTarget target ) =\u003E target.IsBound ? \u0022bound\u0022 : \u0022unbound\u0022;\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\u003CAction\u003E _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\u003Cstring, ValidationSeverity\u003E? 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\u0022background-color: rgb(25,28,32); border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;\u0022 );\n\t\t_identitySpine = new Widget( _identity ) { FixedWidth = 3 };\n\t\t_identitySpine.SetStyles(\n\t\t\t$\u0022background-color: {WeaponAnimatorTheme.Cyan.Hex}; border: none; border-radius: 0px;\u0022 );\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( \u0022NO SELECTION\u0022, identityContent, WeaponAnimatorTheme.Cyan );\n\t\t_name = WeaponAnimatorTheme.Label( \u0022Select a control or bone\u0022, identityContent );\n\t\t_name.SetStyles(\n\t\t\t\u0022background-color: transparent; border: none; padding: 0px;\u0022 \u002B\n\t\t\t$\u0022font-size: 17px; font-weight: 500; color: {WeaponAnimatorTheme.Text.Hex};\u0022 );\n\t\t_details = WeaponAnimatorTheme.Label( \u0022Choose an item in the rig browser.\u0022, identityContent, true );\n\t\t_keyState = WeaponAnimatorTheme.Label( \u0022No key\u0022, 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 \u002B= ( message, severity ) =\u003E StatusChanged?.Invoke( message, severity );\n\t\tLayout.Add( tools, 1 );\n\t\t_controller.SelectionChanged \u002B= RebuildTransform;\n\t\t_controller.DocumentChanged \u002B= Refresh;\n\t\t_controller.PoseChanged \u002B= RefreshPose;\n\t\t_controller.TimelineChanged \u002B= 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\u0022background-color: rgb(22,25,28); border: none; border-bottom: 1px solid rgba(255,255,255,0.06);\u0022 );\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 =\u003E x.Complete );\n\t\tvar next = states.FirstOrDefault( x =\u003E !x.Complete );\n\t\tvar header = RigAuditPanel.Row( _checklist );\n\t\tvar toggle = new WeaponAnimatorButton(\n\t\t\t$\u0022{complete}/5  Grip setup{(next.Label is null ? \u0022 \u00B7 Complete\u0022 : $\u0022 \u00B7 Next: {next.Label}\u0022)}\u0022,\n\t\t\t\u0022checklist\u0022,\n\t\t\theader )\n\t\t{\n\t\t\tClicked = () =\u003E\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( \u0022\u0022, \u0022close\u0022, header )\n\t\t{\n\t\t\tClicked = () =\u003E _controller.Mutate(\n\t\t\t\t\u0022Dismiss binding checklist\u0022,\n\t\t\t\tdocument =\u003E document.Binding.ChecklistDismissed = true ),\n\t\t\tTint = WeaponAnimatorTheme.Surface,\n\t\t\tToolTip = \u0022Dismiss setup guide\u0022\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$\u0022{(captured.Complete ? \u0022\u2713\u0022 : \u0022\u25CB\u0022)}  {captured.Label}\u0022,\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\u003C(string? Label, bool Complete, Action Select)\u003E ChecklistStates()\n\t{\n\t\tvar document = _controller.Document;\n\t\tvar clip = document.GetSelectedClip();\n\t\tvar hasElbow = clip?.Tracks.Any( x =\u003E\n\t\t\t(x.Target is \u0022@primary_elbow\u0022 or \u0022@support_elbow\u0022) \u0026\u0026 x.Keys.Count \u003E 0 ) == true;\n\t\tvar hasFinger = clip?.Tracks.Any( x =\u003E\n\t\t\tx.Target.Contains( \u0022finger\u0022, StringComparison.OrdinalIgnoreCase ) \u0026\u0026 x.Keys.Count \u003E 0 ) == true;\n\t\treturn\n\t\t[\n\t\t\t(\u0022Bind primary hand\u0022, document.Binding.PrimaryHand.IsBound, () =\u003E _controller.SelectControl( \u0022@primary_hand\u0022 )),\n\t\t\t(\u0022Bind support hand\u0022,\n\t\t\t\tdocument.Binding.Configuration == GripConfiguration.OneHanded\n\t\t\t\t\t|| document.Binding.SupportHand.IsBound,\n\t\t\t\t() =\u003E _controller.SelectControl( \u0022@support_hand\u0022 )),\n\t\t\t(\u0022Adjust elbow poles\u0022, hasElbow, () =\u003E _controller.SelectControl( \u0022@primary_elbow\u0022 )),\n\t\t\t(\u0022Pose fingers\u0022, hasFinger, () =\u003E _controller.SelectBone( \u0022finger_index_0_R\u0022 )),\n\t\t\t(\u0022Save default grip pose\u0022,\n\t\t\t\tdocument.Binding.GripPoses.Count \u003E 0,\n\t\t\t\t() =\u003E _controller.SelectControl( \u0022@primary_hand\u0022 ))\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\u002B\u002B;\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\u0022TRANSFORM\u0022,\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 ? \u0022Local\u0022 : \u0022World\u0022;\n\t\t\tAddVectorRow( $\u0022{space} Position\u0022, 0.05f, context, TransformPart.Position );\n\t\t\tAddVectorRow( $\u0022{space} Rotation\u0022, 0.5f, context, TransformPart.Rotation );\n\t\t\tAddVectorRow( $\u0022{space} Scale\u0022, 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\u0022Key pose\u0022,\n\t\t\t\t\u0022diamond\u0022,\n\t\t\t\t() =\u003E\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\u0022Revert\u0022,\n\t\t\t\t\u0022restart_alt\u0022,\n\t\t\t\t() =\u003E\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( \u0022@\u0022, StringComparison.Ordinal ) )\n\t\t\t{\n\t\t\t\tactions.Layout.Add( WeaponAnimatorTheme.Button(\n\t\t\t\t\t\u0022Reset bind\u0022,\n\t\t\t\t\t\u0022settings_backup_restore\u0022,\n\t\t\t\t\t() =\u003E\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( \u0022Auto-key\u0022, \u0022fiber_manual_record\u0022, 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 = () =\u003E\n\t\t{\n\t\t\t_controller.Mutate(\n\t\t\t\t\u0022Auto-key\u0022,\n\t\t\t\tdocument =\u003E 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[] { \u0022X\u0022, \u0022Y\u0022, \u0022Z\u0022 };\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 \u003C 3; index\u002B\u002B )\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 \u002B= () =\u003E\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() =\u003E 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() =\u003E _controller.BeginContinuousEdit( $\u0022{label} {axes[captured]}\u0022 ),\n\t\t\t\tvalue =\u003E 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( () =\u003E\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 \u003C 3; index\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar text = vector[index].ToString( \u00220.###\u0022, 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 ) =\u003E\n\t\t!rebuilding\n\t\t\u0026\u0026 !refreshing\n\t\t\u0026\u0026 fieldGeneration == currentGeneration\n\t\t\u0026\u0026 current is not null\n\t\t\u0026\u0026 current.Target.Equals( expectedTarget, StringComparison.OrdinalIgnoreCase )\n\t\t\u0026\u0026 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\u0022background-color: rgb(25,28,32); border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;\u0022 );\n\t\t\t_identitySpine.SetStyles(\n\t\t\t\t$\u0022background-color: {WeaponAnimatorTheme.Muted.Hex}; border: none; border-radius: 0px;\u0022 );\n\t\t\t_type.Text = \u0022NO SELECTION\u0022;\n\t\t\t_name.Text = \u0022Select a control or bone\u0022;\n\t\t\t_details.Text = \u0022Choose an item in the rig browser.\u0022;\n\t\t\t_keyState.Text = \u0022No key\u0022;\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$\u0022background-color: {accent.Hex}; border: none; border-radius: 0px;\u0022 );\n\t\t_identity.SetStyles(\n\t\t\t\u0022background-color: rgb(25,28,32);\u0022 \u002B\n\t\t\t\u0022border: none; border-bottom: 1px solid rgba(255,255,255,0.07); border-radius: 0px;\u0022 );\n\t\t_name.Text = context.DisplayName;\n\t\t_details.Text = string.IsNullOrWhiteSpace( context.ParentName )\n\t\t\t? \u0022No parent\u0022\n\t\t\t: $\u0022Parent: {context.ParentName}\u0022;\n\t\tvar clip = _controller.Document.GetSelectedClip();\n\t\tvar working = clip is not null\n\t\t\t\u0026\u0026 _controller.Document.Workspace.GetWorkingPose( clip.Id, context.Target ) is not null;\n\t\t_keyState.Text = working\n\t\t\t? \u0022\u25C6 Unkeyed changes\u0022\n\t\t\t: _controller.HasKeyAtPlayhead( context.Target )\n\t\t\t\t? \u0022\u25C6 Keyed at playhead\u0022\n\t\t\t\t: \u0022\u25C7 No key at playhead\u0022;\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 ) =\u003E part switch\n\t{\n\t\tTransformPart.Position =\u003E transform.Position,\n\t\tTransformPart.Rotation =\u003E 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_ =\u003E transform.Scale\n\t};\n\n\tprivate static Transform SetVector(\n\t\tTransform transform,\n\t\tTransformPart part,\n\t\tVector3 value ) =\u003E part switch\n\t{\n\t\tTransformPart.Position =\u003E transform.WithPosition( value ),\n\t\tTransformPart.Rotation =\u003E transform.WithRotation( Rotation.From( value.x, value.y, value.z ).Normal ),\n\t\t_ =\u003E 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; } = \u0022\u0022;\n\tpublic string DisplayName { get; init; } = \u0022\u0022;\n\tpublic string ParentName { get; init; } = \u0022\u0022;\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 =\u003E LocalSpace ? LocalTransform : WorldTransform;\n\tpublic string TypeName =\u003E Target switch\n\t{\n\t\t\u0022@primary_hand\u0022 or \u0022@support_hand\u0022 =\u003E \u0022HAND IK TARGET\u0022,\n\t\t\u0022@primary_elbow\u0022 or \u0022@support_elbow\u0022 =\u003E \u0022ELBOW POLE\u0022,\n\t\t_ when Kind == RigControlKind.Weapon =\u003E \u0022WEAPON BONE\u0022,\n\t\t_ when Kind == RigControlKind.Camera =\u003E \u0022CAMERA BONE\u0022,\n\t\t_ =\u003E \u0022ARM BONE\u0022\n\t};\n\n\tpublic Transform ToLocal( Transform displayed ) =\u003E\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\u0022@primary_hand\u0022 =\u003E document.Binding.PrimaryHand,\n\t\t\t\t\u0022@support_hand\u0022 =\u003E document.Binding.SupportHand,\n\t\t\t\t\u0022@primary_elbow\u0022 =\u003E document.Binding.PrimaryElbowPole,\n\t\t\t\t\u0022@support_elbow\u0022 =\u003E document.Binding.SupportElbowPole,\n\t\t\t\t_ =\u003E 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\u0026\u0026 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 =\u003E\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\u0026\u0026 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\u0026\u0026 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"}]}