Editor/Services/WeaponSourceImporter.cs

Editor service that imports weapon model source files into the project preview cache, wraps non-vmdl sources into a VMDL ModelDoc, compiles and loads the Model, audits bones, discovers and prepares material previews, and updates the WeaponAnimationDocument state.

File AccessNetworking
#nullable enable annotations

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Editor;
using Sandbox;

namespace SboxWeaponAnimator.Editor;

public sealed class SourceImportResult
{
	public bool Success { get; init; }
	public string Message { get; init; } = "";
	public string ModelPath { get; init; } = "";
	public Model? Model { get; init; }
	public Vector3 ModelDimensions { get; init; }
	public List<RigAuditIssue> Issues { get; init; } = [];
	public List<SourceMaterialBinding> Materials { get; init; } = [];
}

public sealed class WeaponSourceImporter
{
	private static readonly HashSet<string> SupportedExtensions =
		new( StringComparer.OrdinalIgnoreCase ) { ".fbx", ".smd", ".dmx", ".vmdl" };

	internal readonly record struct PreviewModelRecoveryCandidate(
		string AbsolutePath,
		DateTime LastWriteUtc,
		bool HasCompiledArtifact,
		bool HasVersionMarker );

	public SourceImportResult Import( WeaponAnimationDocument document, string selectedPath )
	{
		if ( string.IsNullOrWhiteSpace( selectedPath ) )
			return Fail( "No source file was selected." );

		var extension = Path.GetExtension( selectedPath );
		if ( !SupportedExtensions.Contains( extension ) )
			return Fail( "Choose a rigged FBX, SMD, DMX, or VMDL asset." );

		var absoluteSource = ResolveAbsolutePath( selectedPath );
		if ( string.IsNullOrWhiteSpace( absoluteSource ) || !File.Exists( absoluteSource ) )
			return Fail( $"Source file does not exist: {selectedPath}" );

		var importStage = "preparing the source";
		var cacheRoot = "";
		try
		{
			importStage = "hashing the source file";
			var sourceHash = HashFile( absoluteSource );
			importStage = "creating the preview cache";
			cacheRoot = GetPreviewCacheRoot( document.DocumentId );
			Directory.CreateDirectory( cacheRoot );

			importStage = "registering the source asset";
			var cachedSource = EnsureSourceInsideAssets( absoluteSource, cacheRoot, sourceHash );
			var sourceAsset = AssetSystem.RegisterFile( cachedSource );
			if ( sourceAsset is null )
				return Fail( "The Asset System could not register the source file." );

			Asset modelAsset;
			var needsWrapper = !extension.Equals( ".vmdl", StringComparison.OrdinalIgnoreCase );
			if ( needsWrapper )
			{
				importStage = "creating the ModelDoc wrapper";
				var wrapperPath = Path.Combine( cacheRoot, $"source_{sourceHash[..12]}.vmdl" );
				modelAsset = AssetSystem.FindByPath( RelativeAssetPath( wrapperPath ) );
				if ( modelAsset is null )
				{
					modelAsset = EditorUtility.CreateModelFromMeshFile( sourceAsset, wrapperPath );
					if ( modelAsset is null )
						return Fail( "ModelDoc could not create the source VMDL wrapper." );
				}
			}
			else
			{
				importStage = "compiling the VMDL";
				modelAsset = sourceAsset;
				modelAsset.Compile( true );
			}

			importStage = "compiling the model";
			if ( !modelAsset.IsCompiled && !modelAsset.Compile( true ) )
				return Fail( "The source model failed to compile." );

			importStage = "loading the compiled model";
			var model = Model.Load( modelAsset.Path );
			if ( model is null || model.IsError )
				return Fail( $"The compiled model could not be loaded: {modelAsset.Path}" );

			importStage = "auditing the skeleton";
			var bones = AuditBones( model, out var rootName, out var issues );
			var sourceRootBoneName = rootName;
			var materials = new List<SourceMaterialBinding>();
			if ( needsWrapper )
			{
				importStage = "discovering nearby weapon textures";
				materials = WeaponMaterialPipeline.DiscoverAndPreparePreview(
					absoluteSource,
					cacheRoot,
					modelAsset,
					model,
					issues );

				importStage = "applying source material slots";
				var wrapperPath = Path.Combine( cacheRoot, $"source_{sourceHash[..12]}.vmdl" );
				var canNormalizeRoot =
					!string.IsNullOrWhiteSpace( rootName )
					&& !rootName.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase )
					&& bones.All( bone => !bone.Name.Equals(
						"weapon_root",
						StringComparison.OrdinalIgnoreCase ) );
				AtomicFile.WriteAllText(
					wrapperPath,
					ModelDocWriter.WriteSourceWrapper(
						RelativeAssetPath( cachedSource ),
						canNormalizeRoot ? rootName : "",
						materialRemaps: WeaponMaterialPipeline.PreviewRemaps( materials ) ) );
				modelAsset = AssetSystem.RegisterFile( wrapperPath ) ?? modelAsset;
				if ( !modelAsset.Compile( true ) )
					return Fail( "Could not compile the textured source wrapper." );

				model = Model.Load( modelAsset.Path );
				if ( model is null || model.IsError )
					return Fail( "The textured source wrapper did not reload." );
				var materialIssues = issues
					.Where( issue => issue.Code.StartsWith(
						"material.",
						StringComparison.OrdinalIgnoreCase ) )
					.ToArray();
				bones = AuditBones( model, out rootName, out issues );
				issues.AddRange( materialIssues );
			}

			if ( bones.FirstOrDefault( x =>
				x.Classification == WeaponBoneClassification.WeaponRoot ) is { } normalizedRoot )
			{
				normalizedRoot.OriginalName = sourceRootBoneName;
			}

			document.Source.OriginalSourcePath = selectedPath;
			document.Source.SourcePath = RelativeAssetPath( cachedSource );
			document.Source.CompiledModelPath = modelAsset.Path;
			document.Source.SourceHash = sourceHash;
			document.Source.SourceRootBoneName = sourceRootBoneName;
			document.Source.OriginalModelDimensions = model.Bounds.Size;
			document.Source.NeedsModelDocWrapper = needsWrapper;
			document.Source.Compiled = model.BoneCount > 0 && !model.IsError;
			document.Source.PreviewHostCompiled = false;
			document.Source.LastImportedUtc = DateTime.UtcNow;
			document.Source.Materials = materials;
			document.Rig.RootBone = rootName;
			document.Rig.Bones = bones;
			document.Rig.AuditIssues = issues;
			WeaponRigHierarchy.RepairMetadata( document.Rig, false );
			WeaponRigHierarchy.SelectWeaponSubtree( document.Rig, rootName );
			document.Rig.ProfileHash = HashText( WeaponRigHierarchy.ProfileText( document.Rig ) );
			document.Calibration.Confirmed = false;
			document.Calibration.Snapshot = null;

			return new SourceImportResult
			{
				Success = document.Source.Compiled,
				Message = document.Source.Compiled
					? ImportMessage( model, selectedPath, materials )
					: "The model has no usable skeleton.",
				ModelPath = modelAsset.Path,
				Model = model,
				ModelDimensions = model.Bounds.Size,
				Issues = issues,
				Materials = materials
			};
		}
		catch ( Exception ex )
		{
			Log.Error(
				$"[Weapon Animator] source import failed while {importStage}. "
				+ $"Selected='{selectedPath}', resolved='{absoluteSource}', cache='{cacheRoot}'. {ex}" );
			return Fail( $"Import failed while {importStage}: {ex.Message}" );
		}
	}

	public async Task<SourceImportResult> RefreshMaterialsAsync(
		WeaponAnimationDocument document )
	{
		if ( !document.Source.NeedsModelDocWrapper )
			return Fail( "Automatic texture discovery currently applies to imported FBX, SMD, and DMX sources." );

		var selectedPath = new[]
			{
				document.Source.OriginalSourcePath,
				document.Source.SourcePath
			}
			.Where( path => !string.IsNullOrWhiteSpace( path ) )
			.FirstOrDefault( path =>
			{
				try
				{
					return File.Exists( ResolveAbsolutePath( path ) );
				}
				catch
				{
					return false;
				}
			} )
			?? document.Source.OriginalSourcePath;
		var absoluteSource = ResolveAbsolutePath( selectedPath );
		if ( string.IsNullOrWhiteSpace( absoluteSource ) || !File.Exists( absoluteSource ) )
			return Fail( $"Source file does not exist: {selectedPath}" );

		try
		{
			var cacheRoot = GetPreviewCacheRoot( document.DocumentId );
			Directory.CreateDirectory( cacheRoot );
			var sourceHash = string.IsNullOrWhiteSpace( document.Source.SourceHash )
				? HashFile( absoluteSource )
				: document.Source.SourceHash;
			var cachedSource = EnsureSourceInsideAssets(
				absoluteSource,
				cacheRoot,
				sourceHash );
			var legacyWrapperPath = Path.Combine(
				cacheRoot,
				$"source_{sourceHash[..12]}.vmdl" );
			var modelAsset = AssetSystem.FindByPath( document.Source.CompiledModelPath )
				?? AssetSystem.FindByPath( legacyWrapperPath )
				?? AssetSystem.RegisterFile( legacyWrapperPath );
			if ( modelAsset is null )
				return Fail( "The source wrapper is not registered with the Asset System." );

			Model? sourceModel = null;
			try
			{
				sourceModel = modelAsset.LoadResource<Model>() ?? Model.Load( modelAsset.Path );
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] current source wrapper could not be inspected; "
					+ $"saved and embedded slot names will be used instead: {ex.Message}" );
			}

			var issues = new List<RigAuditIssue>();
			var materials = WeaponMaterialPipeline.DiscoverAndPreparePreview(
				absoluteSource,
				cacheRoot,
				modelAsset,
				sourceModel,
				issues,
				document.Source.Materials.Select( material => material.SourceMaterialPath ) );
			if ( materials.Count == 0 )
				return Fail( "No source material slots or nearby texture sets were found." );

			Log.Info(
				$"[Weapon Animator] material refresh matched "
				+ $"{materials.Count( material => material.HasUsableTextures )} of "
				+ $"{materials.Count} slot(s) in preview revision "
				+ $"'{WeaponMaterialPipeline.PreviewRevision( materials )}'." );
			foreach ( var textureAbsolute in
				WeaponMaterialPipeline.PreviewTextureAbsolutePaths( materials ) )
			{
				var textureAsset = AssetSystem.RegisterFile( textureAbsolute );
				if ( textureAsset is null
					|| textureAsset.IsDeleted
					|| !textureAsset.HasSourceFile )
					return Fail(
						$"The preview texture could not be registered: "
						+ $"{RelativeAssetPath( textureAbsolute )}" );
			}
			// Give the directory watcher one frame to retain the newly registered image
			// sources before asking MaterialCompiler to consume them.
			await Task.Delay( 16 );

			foreach ( var materialAbsolute in
				WeaponMaterialPipeline.PreviewMaterialAbsolutePaths( materials ) )
			{
				var materialAsset = AssetSystem.RegisterFile( materialAbsolute );
				if ( materialAsset is null )
					return Fail(
						$"The preview material could not be registered: "
						+ $"{RelativeAssetPath( materialAbsolute )}" );
				if ( !await AssetGenerationService.WaitForCompileAsync(
					materialAsset,
					materialAbsolute ) )
				{
					Log.Error(
						$"[Weapon Animator] preview material compilation failed: "
						+ $"'{materialAsset.Path}'." );
					return Fail(
						$"Preview material compilation failed: {materialAsset.Path}" );
				}
			}

			var revisionRoot = WeaponMaterialPipeline.PreviewRevisionRoot(
				cacheRoot,
				materials );
			var modelRoot = Path.Combine( revisionRoot, "models" );
			Directory.CreateDirectory( modelRoot );
			var wrapperPath = MaterialCandidateWrapperPath(
				cacheRoot,
				sourceHash,
				materials );
			AtomicFile.WriteAllText(
				wrapperPath,
				ModelDocWriter.WriteSourceWrapper(
					RelativeAssetPath( cachedSource ),
					document.Rig.Bones.All( bone =>
						!bone.Name.Equals(
							"weapon_root",
							StringComparison.OrdinalIgnoreCase )
						|| bone.Name.Equals(
							document.Source.SourceRootBoneName,
							StringComparison.OrdinalIgnoreCase ) )
								? document.Source.SourceRootBoneName
								: "",
					materialRemaps: WeaponMaterialPipeline.PreviewRemaps( materials ) ) );
			var candidateAsset = AssetSystem.RegisterFile( wrapperPath );
			if ( candidateAsset is null )
				return Fail( "The candidate textured source wrapper could not be registered." );
			if ( !await AssetGenerationService.WaitForCompileAsync(
				candidateAsset,
				wrapperPath ) )
				return Fail( "The candidate textured source wrapper could not be compiled." );

			var model = await ReloadMaterialPreviewAsync(
				wrapperPath,
				candidateAsset.Path );
			if ( model is null || model.IsError || model.BoneCount == 0 )
				return Fail(
					"The candidate textured source wrapper compiled but did not reload "
					+ "with a usable skeleton." );

			Log.Info(
				$"[Weapon Animator] accepted material preview '{candidateAsset.Path}' "
				+ $"with {model.BoneCount} bones. The active document can now switch safely." );
			return new SourceImportResult
			{
				Success = true,
				Message = ImportMessage( model, selectedPath, materials ),
				ModelPath = candidateAsset.Path,
				Model = model,
				ModelDimensions = model.Bounds.Size,
				Issues = issues,
				Materials = materials
			};
		}
		catch ( Exception ex )
		{
			Log.Error( $"[Weapon Animator] material refresh failed for '{selectedPath}': {ex}" );
			return Fail( $"Material refresh failed: {ex.Message}" );
		}
	}

	public static void ApplyMaterialRefresh(
		WeaponAnimationDocument document,
		SourceImportResult result )
	{
		if ( !result.Success )
			return;

		document.Source.Materials = result.Materials;
		document.Source.CompiledModelPath = result.ModelPath;
		if ( WeaponAnimationMath.IsFinite( result.ModelDimensions )
			&& result.ModelDimensions.Length > 0 )
			document.Source.OriginalModelDimensions = result.ModelDimensions;
		document.Source.Compiled = result.Model is { IsError: false } && result.Model.BoneCount > 0;
		document.Source.LastImportedUtc = DateTime.UtcNow;
		document.Rig.AuditIssues.RemoveAll( issue => issue.Code.StartsWith(
			"material.",
			StringComparison.OrdinalIgnoreCase ) );
		document.Rig.AuditIssues.AddRange( result.Issues );
	}

	public static bool TryRecoverMissingPreviewModel(
		WeaponAnimationDocument document,
		out string message )
	{
		message = "";
		if ( document.Source is null
			|| !document.Source.NeedsModelDocWrapper
			|| SourceAssetExists( document.Source.CompiledModelPath ) )
			return false;

		var previewRoot = Path.Combine(
			GetContentRoot(),
			"weaponanim_preview_cache",
			document.DocumentId.ToString( "N" ) );
		if ( !Directory.Exists( previewRoot ) )
			return false;

		var sourceHash = document.Source.SourceHash;
		var expectedPrefix = sourceHash.Length >= 12
			? $"source_{sourceHash[..12]}_textured"
			: "source_";
		var candidates = Directory.EnumerateFiles(
				previewRoot,
				"source_*_textured.vmdl",
				SearchOption.AllDirectories )
			.Where( path =>
			{
				var filename = Path.GetFileNameWithoutExtension( path );
				return sourceHash.Length < 12
					? filename.StartsWith( expectedPrefix, StringComparison.OrdinalIgnoreCase )
					: filename.Equals( expectedPrefix, StringComparison.OrdinalIgnoreCase );
			} )
			.Select( path => new PreviewModelRecoveryCandidate(
				path,
				File.GetLastWriteTimeUtc( path ),
				File.Exists( path + "_c" ),
				File.Exists( Path.Combine(
					Path.GetDirectoryName( Path.GetDirectoryName( path ) ) ?? "",
					".weaponanim-preview-version" ) ) ) )
			.ToArray();
		var selected = SelectRecoveryCandidate( candidates );
		if ( string.IsNullOrWhiteSpace( selected ) )
			return false;

		var asset = AssetSystem.RegisterFile( selected );
		Model? model = null;
		try
		{
			model = asset?.LoadResource<Model>()
				?? Model.Load( RelativeAssetPath( selected ) );
		}
		catch ( Exception ex )
		{
			Log.Warning(
				$"[Weapon Animator] recovered preview candidate could not be inspected: "
				+ $"{ex.Message}" );
		}
		if ( asset is null || model is null || model.IsError || model.BoneCount == 0 )
			return false;

		var previous = document.Source.CompiledModelPath;
		document.Source.CompiledModelPath = asset.Path;
		document.Source.Compiled = true;
		document.Source.PreviewHostCompiled = false;
		if ( model.Bounds.Size.Length > 0 )
			document.Source.OriginalModelDimensions = model.Bounds.Size;
		message =
			$"Recovered the source weapon preview from '{previous}' to '{asset.Path}' "
			+ $"({model.BoneCount} bones). Save the project to retain the repaired path.";
		Log.Warning( $"[Weapon Animator] {message}" );
		return true;
	}

	internal static string SelectRecoveryCandidateForTests(
		IEnumerable<PreviewModelRecoveryCandidate> candidates ) =>
		SelectRecoveryCandidate( candidates );

	private static string SelectRecoveryCandidate(
		IEnumerable<PreviewModelRecoveryCandidate> candidates ) =>
		candidates
			.Where( candidate => candidate.HasCompiledArtifact )
			.OrderByDescending( candidate => candidate.HasVersionMarker )
			.ThenByDescending( candidate => candidate.LastWriteUtc )
			.ThenBy( candidate => candidate.AbsolutePath, StringComparer.OrdinalIgnoreCase )
			.Select( candidate => candidate.AbsolutePath )
			.FirstOrDefault() ?? "";

	private static bool SourceAssetExists( string relativePath )
	{
		if ( string.IsNullOrWhiteSpace( relativePath ) )
			return false;

		try
		{
			var absolute = Path.IsPathRooted( relativePath )
				? Path.GetFullPath( relativePath )
				: Path.GetFullPath( Path.Combine(
					GetContentRoot(),
					relativePath.TrimStart( '/', '\\' ) ) );
			return File.Exists( absolute ) || File.Exists( absolute + "_c" );
		}
		catch
		{
			return false;
		}
	}

	internal static string MaterialCandidateWrapperPath(
		string cacheRoot,
		string sourceHash,
		IEnumerable<SourceMaterialBinding> materials ) =>
		Path.Combine(
			WeaponMaterialPipeline.PreviewRevisionRoot( cacheRoot, materials ),
			"models",
			$"source_{sourceHash[..12]}_textured.vmdl" );

	public static void CleanupLegacyMaterialPreview(
		WeaponAnimationDocument document )
	{
		var cacheRoot = GetPreviewCacheRoot( document.DocumentId );
		var legacyDirectories = new[]
		{
			Path.Combine( cacheRoot, "materials" ),
			Path.Combine( cacheRoot, "texture-definitions" ),
			Path.Combine( cacheRoot, "source-textures" )
		};
		var staleFiles = legacyDirectories
			.Where( Directory.Exists )
			.SelectMany( directory => Directory.EnumerateFiles(
				directory,
				"*",
				SearchOption.AllDirectories ) )
			.ToList();

		if ( staleFiles.Count > 0 )
		{
			AssetGenerationService.DeleteGeneratedFiles( staleFiles );
			foreach ( var directory in legacyDirectories
				.Where( Directory.Exists )
				.OrderByDescending( directory => directory.Length ) )
			{
				try
				{
					Directory.Delete( directory, true );
				}
				catch ( Exception ex )
				{
					Log.Warning(
						$"[Weapon Animator] could not remove legacy preview material folder "
						+ $"'{directory}': {ex.Message}" );
				}
			}
		}
		CleanupUnversionedLegalPreviews( document, staleFiles );
		if ( staleFiles.Count > 0 )
		{
			Log.Info(
				$"[Weapon Animator] removed {staleFiles.Count} obsolete preview material source(s)." );
		}
	}

	private static void CleanupUnversionedLegalPreviews(
		WeaponAnimationDocument document,
		List<string> removedFiles )
	{
		var previewRoot = Path.Combine(
			GetContentRoot(),
			"weaponanim_preview_cache",
			document.DocumentId.ToString( "N" ) );
		if ( !Directory.Exists( previewRoot ) )
			return;

		var protectedModel = string.IsNullOrWhiteSpace(
			document.Source.CompiledModelPath )
				? ""
				: Path.GetFullPath( Path.Combine(
					GetContentRoot(),
					document.Source.CompiledModelPath.Replace(
						'/',
						Path.DirectorySeparatorChar ) ) );
		foreach ( var revisionDirectory in Directory.EnumerateDirectories( previewRoot )
			.Where( directory => !Path.GetFileName( directory ).Equals(
				"source-textures",
				StringComparison.OrdinalIgnoreCase ) ) )
		{
			var marker = Path.Combine(
				revisionDirectory,
				".weaponanim-preview-version" );
			var isProtected = !string.IsNullOrWhiteSpace( protectedModel )
				&& protectedModel.StartsWith(
					Path.GetFullPath( revisionDirectory )
						+ Path.DirectorySeparatorChar,
					StringComparison.OrdinalIgnoreCase );
			if ( isProtected || File.Exists( marker ) )
				continue;

			var revisionFiles = Directory.EnumerateFiles(
				revisionDirectory,
				"*",
				SearchOption.AllDirectories )
				.ToArray();
			AssetGenerationService.DeleteGeneratedFiles( revisionFiles );
			removedFiles.AddRange( revisionFiles );
			try
			{
				Directory.Delete( revisionDirectory, true );
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] could not remove obsolete preview revision "
					+ $"'{revisionDirectory}': {ex.Message}" );
			}
		}
	}

	private static async Task<Model?> ReloadMaterialPreviewAsync(
		string wrapperAbsolute,
		string wrapperPath )
	{
		var deadline = DateTime.UtcNow.AddSeconds( 20 );
		var nextProgressLog = DateTime.UtcNow.AddSeconds( 2 );
		Model? model = null;
		while ( DateTime.UtcNow <= deadline )
		{
			await Task.Delay( 50 );
			var asset = AssetSystem.FindByPath( wrapperAbsolute );
			try
			{
				model = asset?.LoadResource<Model>() ?? Model.Load( wrapperPath );
			}
			catch ( Exception ex )
			{
				Log.Warning(
					$"[Weapon Animator] preview material model reload attempt threw: {ex.Message}" );
				model = null;
			}

			if ( model is not null && !model.IsError && model.BoneCount > 0 )
				return model;

			if ( DateTime.UtcNow >= nextProgressLog )
			{
				Log.Info(
					$"[Weapon Animator] waiting for material preview '{wrapperPath}': "
					+ $"bones={model?.BoneCount ?? 0}, "
					+ $"error={model?.IsError.ToString() ?? "not loaded"}, "
					+ $"assetPresent={asset is not null}, "
					+ $"compiledArtifact={File.Exists( wrapperAbsolute + "_c" )}." );
				nextProgressLog = DateTime.UtcNow.AddSeconds( 2 );
			}
		}

		return model;
	}

	private static string ImportMessage(
		Model model,
		string selectedPath,
		IReadOnlyCollection<SourceMaterialBinding> materials )
	{
		var textured = materials.Count( material => material.HasUsableTextures );
		var materialSummary = materials.Count == 0
			? " No source material slots were reported."
			: $" Matched textures for {textured} of {materials.Count} material slot(s).";
		return $"Imported {model.BoneCount} bones from {Path.GetFileName( selectedPath )}."
			+ materialSummary
			+ (WeaponSourceFormatSupport.CanGenerate( selectedPath )
				? ""
				: " SMD generation is unavailable in S&box ModelDoc; re-export as FBX or DMX "
					+ "before generating the final viewmodel.");
	}

	private static List<WeaponBoneDefinition> AuditBones(
		Model model,
		out string rootName,
		out List<RigAuditIssue> issues )
	{
		issues = [];
		var allBones = model.Bones.AllBones;
		rootName = allBones.FirstOrDefault( x =>
			x.Name.Equals( "weapon_root", StringComparison.OrdinalIgnoreCase ) )?.Name
			?? allBones.FirstOrDefault( x => x.Parent is null )?.Name
			?? "";

		if ( allBones.Count == 0 )
		{
			issues.Add( Issue(
				"missing_skinning",
				"The model exposes no bones or skinning data.",
				ValidationSeverity.Error ) );
		}

		var rootCount = allBones.Count( x => x.Parent is null );
		if ( rootCount != 1 )
		{
			issues.Add( Issue(
				"invalid_roots",
				$"Expected one skeleton root but found {rootCount}.",
				ValidationSeverity.Error ) );
		}

		foreach ( var duplicate in allBones
			.GroupBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
			.Where( x => x.Count() > 1 ) )
		{
			issues.Add( Issue(
				"duplicate_bone",
				$"Duplicate bone name '{duplicate.Key}'.",
				ValidationSeverity.Error,
				duplicate.Key ) );
		}

		var selectedRoot = rootName;
		var result = allBones.Select( bone => new WeaponBoneDefinition
		{
			Name = bone.Name,
			ParentName = bone.Parent?.Name ?? "",
			OriginalName = bone.Name,
			OriginalParentName = bone.Parent?.Name ?? "",
			Classification = bone.Name.Equals( selectedRoot, StringComparison.OrdinalIgnoreCase )
				? WeaponBoneClassification.WeaponRoot
				: WeaponBoneClassification.Animatable,
			Inclusion = WeaponBoneInclusion.Included,
			BindTransform = bone.LocalTransform,
			BindModelTransform = bone.LocalTransform,
			BindLocalTransform = bone.Parent is null
				? bone.LocalTransform
				: bone.Parent.LocalTransform.ToLocal( bone.LocalTransform ),
			HasSkinInfluence = true
		} ).ToList();
		var rig = new WeaponRigDefinition
		{
			RootBone = rootName,
			Bones = result
		};
		WeaponRigHierarchy.RepairMetadata( rig, false );
		return result;
	}

	private static string EnsureSourceInsideAssets( string source, string cacheRoot, string hash )
	{
		var assetsRoot = GetContentRoot().NormalizeFilename( false );
		var normalized = source.NormalizeFilename( false );
		var relative = Path.GetRelativePath( assetsRoot, normalized );
		if ( !Path.IsPathRooted( relative )
			&& !relative.Equals( "..", StringComparison.Ordinal )
			&& !relative.StartsWith( $"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal ) )
			return source;

		var filename = $"{Path.GetFileNameWithoutExtension( source )}_{hash[..12]}{Path.GetExtension( source )}";
		var destination = Path.Combine( cacheRoot, filename );
		if ( !File.Exists( destination ) || HashFile( destination ) != hash )
			File.Copy( source, destination, true );
		return destination;
	}

	public static string GetPreviewCacheRoot( Guid documentId )
	{
		return Path.Combine( GetContentRoot(), ".weaponanim-cache", documentId.ToString( "N" ) );
	}

	private static string ResolveAbsolutePath( string path )
	{
		if ( Path.IsPathRooted( path ) )
			return Path.GetFullPath( path );
		return Path.GetFullPath( Path.Combine( GetContentRoot(), path.TrimStart( '/', '\\' ) ) );
	}

	public static string RelativeAssetPath( string absolute )
	{
		return Path.GetRelativePath( GetContentRoot(), absolute ).Replace( '\\', '/' );
	}

	internal static string GetContentRoot()
	{
		var root = global::Editor.FileSystem.Content.GetFullPath( "/" );
		if ( string.IsNullOrWhiteSpace( root ) )
			throw new InvalidOperationException( "The current project's Assets directory is unavailable." );
		return Path.GetFullPath( root );
	}

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

	private static RigAuditIssue Issue(
		string code,
		string message,
		ValidationSeverity severity,
		string bone = "" ) => new()
	{
		Code = code,
		Message = message,
		Severity = severity,
		BoneName = bone
	};

	public static string HashFile( string path )
	{
		using var stream = File.OpenRead( path );
		return Convert.ToHexString( SHA256.HashData( stream ) ).ToLowerInvariant();
	}

	public static string HashText( string value ) =>
		Convert.ToHexString( SHA256.HashData( System.Text.Encoding.UTF8.GetBytes( value ) ) )
			.ToLowerInvariant();
}