Editor/Conversion/Util/PathResolve.cs

Utility class for resolving and normalizing filesystem and game asset paths used by the editor importer. It normalizes slashes, strips extensions, finds Garry's Mod root by looking for models/materials folders, builds relative model/material tokens, counts files, sanitizes asset names, and converts absolute paths into Assets-relative paths.

File Access
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;

namespace GmodAssetImporter;

public static class PathResolve
{
	public static string NormalizeSlashes( string path )
	{
		return (path ?? "").Replace( '\\', '/' ).Trim();
	}

	public static string StripExtension( string path )
	{
		path = NormalizeSlashes( path );
		var idx = path.LastIndexOf( '.' );
		return idx > 0 ? path[..idx] : path;
	}

	public static string GetFileNameWithoutExtension( string path )
	{
		path = NormalizeSlashes( path );
		var name = Path.GetFileNameWithoutExtension( path );
		return name ?? "";
	}

	/// <summary>
	/// Walk upward from <paramref name="startPath"/> looking for a folder that contains models/ or materials/.
	/// </summary>
	public static string DetectGmodRoot( string startPath )
	{
		if ( string.IsNullOrWhiteSpace( startPath ) )
			return "";

		var dir = Directory.Exists( startPath ) ? startPath : Path.GetDirectoryName( startPath );
		while ( !string.IsNullOrEmpty( dir ) )
		{
			var models = Path.Combine( dir, "models" );
			var materials = Path.Combine( dir, "materials" );
			if ( Directory.Exists( models ) || Directory.Exists( materials ) )
				return dir;

			// GarrysMod\garrysmod layout
			var garrysmod = Path.Combine( dir, "garrysmod" );
			if ( Directory.Exists( Path.Combine( garrysmod, "models" ) ) || Directory.Exists( Path.Combine( garrysmod, "materials" ) ) )
				return garrysmod;

			dir = Path.GetDirectoryName( dir );
		}

		return "";
	}

	/// <summary>
	/// If path is under gmodRoot/models/..., return models/... relative path without extension.
	/// </summary>
	public static string GetModelsRelativeDirectory( string mdlPath, string gmodRoot )
	{
		mdlPath = NormalizeSlashes( Path.GetFullPath( mdlPath ) );
		gmodRoot = NormalizeSlashes( Path.GetFullPath( gmodRoot ?? "" ) );

		if ( string.IsNullOrEmpty( gmodRoot ) )
		{
			var dir = NormalizeSlashes( Path.GetDirectoryName( mdlPath ) ?? "" );
			var idx = dir.IndexOf( "/models/", StringComparison.OrdinalIgnoreCase );
			if ( idx >= 0 )
				return dir[(idx + 1)..];
			return "models";
		}

		var modelsRoot = NormalizeSlashes( Path.Combine( gmodRoot, "models" ) );
		var mdlDir = NormalizeSlashes( Path.GetDirectoryName( mdlPath ) ?? "" );
		if ( mdlDir.StartsWith( modelsRoot, StringComparison.OrdinalIgnoreCase ) )
		{
			var rel = mdlDir[modelsRoot.Length..].TrimStart( '/' );
			return string.IsNullOrEmpty( rel ) ? "models" : $"models/{rel}";
		}

		return "models";
	}

	/// <summary>
	/// Relative materials path for a VTF/VMT under materials/… (no extension), e.g. models/weapons/gun.
	/// </summary>
	public static string GetMaterialsRelativeToken( string materialFilePath, string gmodRoot )
	{
		materialFilePath = NormalizeSlashes( Path.GetFullPath( materialFilePath ) );
		gmodRoot = string.IsNullOrWhiteSpace( gmodRoot ) ? "" : NormalizeSlashes( Path.GetFullPath( gmodRoot ) );

		string relative = null;
		if ( !string.IsNullOrEmpty( gmodRoot ) )
		{
			var materialsRoot = NormalizeSlashes( Path.Combine( gmodRoot, "materials" ) );
			if ( materialFilePath.StartsWith( materialsRoot + "/", StringComparison.OrdinalIgnoreCase )
				|| materialFilePath.StartsWith( materialsRoot, StringComparison.OrdinalIgnoreCase ) )
			{
				relative = materialFilePath.Length > materialsRoot.Length
					? materialFilePath[materialsRoot.Length..].TrimStart( '/' )
					: "";
			}
		}

		if ( relative == null )
		{
			var idx = materialFilePath.IndexOf( "/materials/", StringComparison.OrdinalIgnoreCase );
			if ( idx >= 0 )
				relative = materialFilePath[(idx + "/materials/".Length)..];
		}

		relative ??= Path.GetFileName( materialFilePath );
		return StripExtension( relative.Trim( '/' ) );
	}

	/// <summary>
	/// Count Source1 texture/material files under a folder (for UI / batch detection).
	/// </summary>
	public static (int Vtf, int Vmt, int Mdl) CountSourceAssets( string folder, bool recursive )
	{
		if ( !Directory.Exists( folder ) )
			return (0, 0, 0);

		var search = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
		return (
			Directory.GetFiles( folder, "*.vtf", search ).Length,
			Directory.GetFiles( folder, "*.vmt", search ).Length,
			Directory.GetFiles( folder, "*.mdl", search ).Length
		);
	}

	public static string CombineAsset( params string[] parts )
	{
		return NormalizeSlashes( string.Join( "/", parts.Where( p => !string.IsNullOrWhiteSpace( p ) ).Select( p => p.Trim( '/' ) ) ) );
	}

	public static IEnumerable<string> EnumerateMaterialSearchRoots( string gmodRoot, IEnumerable<string> cdMaterials )
	{
		if ( !string.IsNullOrWhiteSpace( gmodRoot ) )
		{
			yield return Path.Combine( gmodRoot, "materials" );
			yield return gmodRoot;
		}

		foreach ( var cd in cdMaterials ?? Array.Empty<string>() )
		{
			var clean = NormalizeSlashes( cd ).Trim( '/' );
			if ( string.IsNullOrEmpty( clean ) )
				continue;

			if ( !string.IsNullOrWhiteSpace( gmodRoot ) )
				yield return Path.Combine( gmodRoot, "materials", clean.Replace( '/', Path.DirectorySeparatorChar ) );
		}
	}

	public static string FindExistingFile( IEnumerable<string> candidates )
	{
		foreach ( var c in candidates )
		{
			if ( !string.IsNullOrWhiteSpace( c ) && File.Exists( c ) )
				return c;
		}

		return null;
	}

	public static string SanitizeAssetName( string name )
	{
		name = StripMeshSourceExtension( NormalizeSlashes( name ?? "" ).Trim( '/' ) );
		// Material tokens can be paths — keep leaf name only for FBX/VMDL slots.
		name = Path.GetFileName( name ) ?? name;
		name = StripMeshSourceExtension( name );
		foreach ( var c in Path.GetInvalidFileNameChars() )
			name = name.Replace( c, '_' );
		return name.Replace( ' ', '_' );
	}

	/// <summary>
	/// ModelDoc DefaultMaterialGroup "from" name: FBX material slot + ".vmat".
	/// </summary>
	public static string MaterialRemapFromName( string sourceMaterialName )
	{
		var slot = MaterialSlotName( sourceMaterialName );
		if ( string.IsNullOrEmpty( slot ) )
			slot = "default";
		return slot + ".vmat";
	}

	/// <summary>
	/// Leaf material name with only known asset extensions stripped, so names like
	/// "gun.body" and "gun.mag" stay distinct slots.
	/// </summary>
	public static string MaterialSlotName( string sourceMaterialName )
	{
		var slot = SanitizeAssetName( sourceMaterialName ?? "" );
		return string.IsNullOrEmpty( slot ) ? "default" : slot;
	}

	/// <summary>
	/// Strip trailing mesh/source extensions (.smd, .fbx, …) so names don't become foo.smd.fbx.
	/// </summary>
	public static string StripMeshSourceExtension( string name )
	{
		name = NormalizeSlashes( name ?? "" );
		string[] exts = { ".smd", ".fbx", ".dmx", ".obj", ".mdl", ".vmdl", ".vta", ".vmt", ".vmat" };
		var stripped = true;
		while ( stripped )
		{
			stripped = false;
			foreach ( var ext in exts )
			{
				if ( name.EndsWith( ext, StringComparison.OrdinalIgnoreCase ) )
				{
					name = name[..^ext.Length];
					stripped = true;
				}
			}
		}

		return name;
	}

	/// <summary>
	/// Convert an absolute path (or path under output root) into a game asset path
	/// relative to the project's Assets folder, e.g. test/models/weapons/gun.fbx.
	/// </summary>
	public static string MakeAssetsRelative( string absolutePath, string outputRoot = null )
	{
		if ( string.IsNullOrWhiteSpace( absolutePath ) )
			return "";

		var abs = NormalizeSlashes( Path.GetFullPath( absolutePath ) );
		const string marker = "/assets/";
		var idx = abs.IndexOf( marker, StringComparison.OrdinalIgnoreCase );
		if ( idx >= 0 )
			return abs[(idx + marker.Length)..].Trim( '/' );

		if ( !string.IsNullOrWhiteSpace( outputRoot ) )
		{
			var root = NormalizeSlashes( Path.GetFullPath( outputRoot ) ).TrimEnd( '/' );
			if ( abs.StartsWith( root + "/", StringComparison.OrdinalIgnoreCase ) )
				return abs[(root.Length + 1)..];
			if ( abs.Equals( root, StringComparison.OrdinalIgnoreCase ) )
				return "";
		}

		return Path.GetFileName( abs ) ?? "";
	}

	/// <summary>
	/// Absolute file under OutputRoot → Assets-relative game path.
	/// </summary>
	public static string AssetPathUnderOutput( string outputRoot, string relativeUnderOutput )
	{
		var abs = Path.Combine( outputRoot ?? "", (relativeUnderOutput ?? "").Replace( '/', Path.DirectorySeparatorChar ) );
		return MakeAssetsRelative( abs, outputRoot );
	}
}