Editor/Converters/FabModelConverter.cs
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
namespace FabBridge.Converters;
/// <summary>
/// Converts Fab mesh files (FBX) to s&box VMDL format, bundling LODs into a single VMDL.
/// </summary>
public static class FabModelConverter
{
/// <summary>
/// Fab/Megascans quality tier folder names in descending detail order. "raw" is deliberately
/// excluded — that folder holds the source asset (often a ZBrush .ztl), not a LOD-friendly mesh.
/// Tiers below the primary mesh's tier are promoted to LOD1, LOD2, ...
/// </summary>
private static readonly string[][] QualityTierOrder =
{
new[] { "high" },
new[] { "medium", "mid" },
new[] { "low" },
};
/// <summary>
/// Result of a model conversion
/// </summary>
public class ConversionResult
{
public bool Success { get; set; }
public string SourcePath { get; set; }
public string DestinationPath { get; set; }
public string VmdlPath { get; set; }
public string RelativePath { get; set; }
public string Error { get; set; }
public Asset Asset { get; set; }
public int LodCount { get; set; }
}
/// <summary>
/// Convert a Fab mesh (no LODs) to s&box VMDL format
/// </summary>
public static ConversionResult Convert( FabMesh fabMesh, string assetBaseName, string destinationFolder, bool generateCollisions = false )
{
return ConvertWithLods( new List<string> { fabMesh.GetFilePath() }, assetBaseName, destinationFolder, generateCollisions );
}
/// <summary>
/// Convert a mesh from a file path (single LOD)
/// </summary>
public static ConversionResult ConvertFromPath( string sourcePath, string assetBaseName, string destinationFolder, bool generateCollisions = false )
{
return ConvertWithLods( new List<string> { sourcePath }, assetBaseName, destinationFolder, generateCollisions );
}
/// <summary>
/// Convert a mesh and its LODs into a single VMDL asset.
/// </summary>
/// <param name="lodSourcePaths">Ordered source FBX paths. Index 0 is LOD0 (primary).</param>
/// <param name="assetBaseName">Base name for the resulting VMDL/FBX files.</param>
/// <param name="destinationFolder">Absolute path to destination folder (typically models/).</param>
/// <param name="generateCollisions">If true, append a PhysicsHullFromRender block so the engine auto-generates convex-hull collision.</param>
public static ConversionResult ConvertWithLods( List<string> lodSourcePaths, string assetBaseName, string destinationFolder, bool generateCollisions = false )
{
var result = new ConversionResult();
try
{
if ( lodSourcePaths == null || lodSourcePaths.Count == 0 )
{
result.Error = "No source paths provided";
return result;
}
// Filter out missing/empty entries while preserving order
var validSources = lodSourcePaths
.Where( p => !string.IsNullOrEmpty( p ) && File.Exists( p ) )
.ToList();
if ( validSources.Count == 0 )
{
result.Error = $"None of the source files exist (primary: {lodSourcePaths[0]})";
return result;
}
result.SourcePath = validSources[0];
Directory.CreateDirectory( destinationFolder );
// Copy each LOD source into the destination folder under a predictable name.
var copiedPaths = new List<string>();
for ( int i = 0; i < validSources.Count; i++ )
{
var src = validSources[i];
var ext = Path.GetExtension( src );
var destName = validSources.Count > 1 ? $"{assetBaseName}_lod{i}{ext}" : $"{assetBaseName}{ext}";
var dest = Path.Combine( destinationFolder, destName );
File.Copy( src, dest, overwrite: true );
copiedPaths.Add( dest );
Log.Info( $"FabBridge: Copied LOD{i} mesh to {dest}" );
}
result.DestinationPath = copiedPaths[0];
// Register each mesh asset so the asset system knows about them before the VMDL
// references them by relative path. Skipping this leaves the VMDL compile chasing
// unregistered dependencies.
var meshAssets = new List<Asset>();
foreach ( var p in copiedPaths )
{
var a = AssetSystem.RegisterFile( p ) ?? AssetSystem.FindByPath( p );
if ( a == null )
Log.Warning( $"FabBridge: Failed to register mesh asset {p}" );
meshAssets.Add( a );
}
if ( meshAssets[0] == null )
{
result.Error = "Failed to register primary mesh asset";
return result;
}
// Bootstrap the VMDL from LOD0 — this gives us correct import_scale/rotation/units
// detection that we'd otherwise have to guess.
var vmdlPath = Path.Combine( destinationFolder, $"{assetBaseName}.vmdl" );
result.VmdlPath = vmdlPath;
Asset vmdlAsset = EditorUtility.CreateModelFromMeshFile( meshAssets[0], vmdlPath );
if ( vmdlAsset == null )
{
// File already existed — pick it up via the asset system.
vmdlAsset = AssetSystem.FindByPath( vmdlPath );
}
if ( vmdlAsset == null || !File.Exists( vmdlPath ) )
{
result.Error = "Failed to create VMDL bootstrap";
return result;
}
result.Asset = vmdlAsset;
result.RelativePath = vmdlAsset.Path;
result.LodCount = copiedPaths.Count;
// Collect project-relative paths for any additional LODs that registered cleanly.
var lodRelativePaths = new List<string>();
for ( int i = 0; i < meshAssets.Count; i++ )
{
var a = meshAssets[i];
if ( a == null )
{
Log.Warning( $"FabBridge: Skipping LOD{i} from VMDL — asset not registered" );
continue;
}
lodRelativePaths.Add( a.Path.Replace( '\\', '/' ) );
}
// Apply post-bootstrap modifications: LOD expansion (multi-LOD only) and collision injection.
var vmdlText = File.ReadAllText( vmdlPath );
var modified = false;
if ( lodRelativePaths.Count >= 2 )
{
var rewritten = AddLodsToVmdl( vmdlText, lodRelativePaths );
if ( rewritten != null )
{
vmdlText = rewritten;
modified = true;
Log.Info( $"FabBridge: Expanded VMDL to {lodRelativePaths.Count} LOD level(s)" );
}
else
{
Log.Warning( "FabBridge: LOD rewrite produced no changes; VMDL kept as single-LOD" );
}
}
if ( generateCollisions && !VmdlHasPhysicsShape( vmdlText ) )
{
vmdlText = AddPhysicsShapeList( vmdlText );
modified = true;
Log.Info( "FabBridge: Added PhysicsHullFromRender for engine-generated collision" );
}
if ( modified )
{
File.WriteAllText( vmdlPath, vmdlText );
// Drop stale compiled output so the asset system picks up our new source.
var compiledPath = vmdlPath + "_c";
if ( File.Exists( compiledPath ) )
{
try { File.Delete( compiledPath ); }
catch ( Exception delEx ) { Log.Warning( $"FabBridge: Couldn't delete stale {compiledPath}: {delEx.Message}" ); }
}
}
result.Success = true;
}
catch ( Exception ex )
{
result.Error = ex.Message;
Log.Error( $"FabBridge: Model conversion failed: {ex.Message}" );
Log.Error( $"FabBridge: Stack trace: {ex.StackTrace}" );
}
return result;
}
/// <summary>
/// Convert all meshes from a Fab asset, bundling each mesh's LODs into a single VMDL.
/// </summary>
public static List<ConversionResult> ConvertAllMeshes( FabAsset fabAsset, string destinationFolder, bool generateCollisions = false, bool detectLods = true )
{
var results = new List<ConversionResult>();
var baseName = fabAsset.GetSafeFileName();
var allMeshes = fabAsset.GetAllMeshes();
Log.Info( $"FabModelConverter: Found {allMeshes.Count} mesh entries" );
// If a single mesh has no per-mesh Lods, fall back to the asset-level LodList. This
// matches older Quixel Bridge exports that put LODs in a top-level array.
var useFallbackLodList = allMeshes.Count == 1 && fabAsset.LodList?.Count > 0;
for ( int i = 0; i < allMeshes.Count; i++ )
{
var mesh = allMeshes[i];
var lodPaths = mesh.GetLodFilePaths( useFallbackLodList ? fabAsset.LodList : null );
// Fab downloads quality tiers as independent files in sibling "high"/"medium"/"low" folders.
// If the JSON didn't already provide a LOD chain, see if those tiers exist on disk next to
// the primary mesh and promote them to additional LOD slots. Disabled when the user has
// turned off LOD detection in the widget.
if ( detectLods && lodPaths.Count == 1 )
{
var siblingLods = DiscoverQualityTierLods( lodPaths[0] );
if ( siblingLods.Count > lodPaths.Count )
{
Log.Info( $"FabModelConverter: Found {siblingLods.Count - 1} sibling quality tier(s) to use as LODs" );
lodPaths = siblingLods;
}
}
Log.Info( $"FabModelConverter: Mesh {i} has {lodPaths.Count} LOD level(s)" );
for ( int li = 0; li < lodPaths.Count; li++ )
Log.Info( $" LOD{li}: {lodPaths[li]}" );
if ( lodPaths.Count == 0 )
{
Log.Warning( $"FabModelConverter: Mesh {i} has no file path" );
continue;
}
var meshName = allMeshes.Count > 1 ? $"{baseName}_{i}" : baseName;
var result = ConvertWithLods( lodPaths, meshName, destinationFolder, generateCollisions );
results.Add( result );
}
// Handle components that happen to be mesh files (uncommon — Components are usually textures).
if ( fabAsset.Components != null )
{
foreach ( var component in fabAsset.Components )
{
if ( IsMeshFile( component.Path ) )
{
var componentName = !string.IsNullOrEmpty( component.Name )
? $"{baseName}_{component.Name.ToLowerInvariant().Replace( " ", "_" )}"
: baseName;
var result = ConvertWithLods( new List<string> { component.Path }, componentName, destinationFolder, generateCollisions );
results.Add( result );
}
}
}
return results;
}
/// <summary>
/// Check if a file path is a mesh file
/// </summary>
private static bool IsMeshFile( string path )
{
if ( string.IsNullOrEmpty( path ) )
return false;
var ext = Path.GetExtension( path )?.ToLowerInvariant();
return ext == ".fbx" || ext == ".obj" || ext == ".gltf" || ext == ".glb" || ext == ".dae";
}
/// <summary>
/// Set the default material in a VMDL file
/// </summary>
public static bool SetDefaultMaterial( string vmdlPath, string materialPath )
{
try
{
if ( !File.Exists( vmdlPath ) )
{
Log.Warning( $"FabModelConverter: VMDL file not found: {vmdlPath}" );
return false;
}
var content = File.ReadAllText( vmdlPath );
var materialPattern = new Regex( @"global_default_material\s*=\s*""[^""]*""" );
if ( materialPattern.IsMatch( content ) )
{
content = materialPattern.Replace( content, $"global_default_material = \"{materialPath}\"" );
Log.Info( $"FabModelConverter: Updated global_default_material to {materialPath}" );
}
else
{
Log.Warning( $"FabModelConverter: No global_default_material found in VMDL, cannot set material" );
return false;
}
File.WriteAllText( vmdlPath, content );
Log.Info( $"FabModelConverter: Updated VMDL file, asset system will recompile automatically" );
return true;
}
catch ( Exception ex )
{
Log.Error( $"FabModelConverter: Failed to set default material: {ex.Message}" );
return false;
}
}
/// <summary>
/// Set the default material for a conversion result
/// </summary>
public static bool SetDefaultMaterial( ConversionResult modelResult, FabMaterialConverter.ConversionResult materialResult )
{
if ( modelResult == null || !modelResult.Success || string.IsNullOrEmpty( modelResult.VmdlPath ) )
{
Log.Warning( "FabModelConverter: Invalid model result, cannot set material" );
return false;
}
if ( materialResult == null || !materialResult.Success || string.IsNullOrEmpty( materialResult.RelativePath ) )
{
Log.Warning( "FabModelConverter: Invalid material result, cannot set material" );
return false;
}
var normalizedMaterialPath = materialResult.RelativePath?.Replace( '\\', '/' );
return SetDefaultMaterial( modelResult.VmdlPath, normalizedMaterialPath );
}
// -----------------------------------------------------------------------
// VMDL LOD post-processing
// -----------------------------------------------------------------------
/// <summary>
/// Adds LODGroupList and additional RenderMeshFile entries to a bootstrap VMDL.
/// Returns null if the input doesn't look like a single-mesh VMDL we can patch.
/// </summary>
private static string AddLodsToVmdl( string vmdlText, List<string> lodFilenames )
{
if ( string.IsNullOrEmpty( vmdlText ) || lodFilenames == null || lodFilenames.Count < 2 )
return null;
// Modern bootstrap (modeldoc30) writes its own auto-simplify LODGroupList. Strip any
// existing LODGroupList blocks so we don't end up with duplicates fighting our explicit chain.
vmdlText = RemoveBlocksByClass( vmdlText, "LODGroupList" );
// Locate the existing RenderMeshFile block (the one CreateModelFromMeshFile produced).
var rmFileMatch = Regex.Match( vmdlText, @"_class\s*=\s*""RenderMeshFile""" );
if ( !rmFileMatch.Success )
{
Log.Warning( "FabBridge: AddLodsToVmdl - no RenderMeshFile found in bootstrap VMDL" );
return null;
}
// The block-opening '{' lives just before the _class line.
int openBrace = vmdlText.LastIndexOf( '{', rmFileMatch.Index );
if ( openBrace < 0 )
return null;
int closeBrace = FindMatchingBrace( vmdlText, openBrace );
if ( closeBrace < 0 )
return null;
var originalBlock = vmdlText.Substring( openBrace, closeBrace - openBrace + 1 );
// LOD0 keeps the bootstrap's import_filter — it was derived from LOD0's FBX content.
var lod0Block = EnsureNamedRenderMeshFile( originalBlock, "LOD0", lodFilenames[0] );
var sb = new StringBuilder();
sb.Append( lod0Block );
for ( int i = 1; i < lodFilenames.Count; i++ )
{
// The bootstrap's filter names a mesh that exists only inside LOD0's FBX. Each lower-tier
// LOD FBX contains a differently-named mesh, so we must reset to a permissive filter or
// nothing gets imported and the LOD renders empty (invisible at switch distance).
var lodBlock = EnsureNamedRenderMeshFile( originalBlock, $"LOD{i}", lodFilenames[i] );
lodBlock = ResetImportFilter( lodBlock );
sb.Append( ",\n\t\t\t\t" );
sb.Append( lodBlock );
}
var withExtraMeshes = vmdlText.Substring( 0, openBrace ) + sb.ToString() + vmdlText.Substring( closeBrace + 1 );
// Insert LODGroupList as the first child of rootNode.
var rootChildrenMatch = Regex.Match( withExtraMeshes, @"_class\s*=\s*""RootNode""[\s\S]*?children\s*=\s*\[" );
if ( !rootChildrenMatch.Success )
{
Log.Warning( "FabBridge: AddLodsToVmdl - couldn't find rootNode children to insert LODGroupList" );
return withExtraMeshes; // Better to ship a multi-mesh VMDL than nothing.
}
int insertPos = rootChildrenMatch.Index + rootChildrenMatch.Length;
var lodGroupList = BuildLodGroupList( lodFilenames.Count );
return withExtraMeshes.Substring( 0, insertPos )
+ "\n\t\t\t" + lodGroupList + ","
+ withExtraMeshes.Substring( insertPos );
}
/// <summary>
/// Returns a copy of a RenderMeshFile block with `name` and `filename` set to the given values.
/// </summary>
private static string EnsureNamedRenderMeshFile( string block, string lodName, string filename )
{
var result = block;
// Replace or insert `name = "..."`
if ( Regex.IsMatch( result, @"\bname\s*=\s*""[^""]*""" ) )
{
result = Regex.Replace( result, @"\bname\s*=\s*""[^""]*""", $"name = \"{lodName}\"" );
}
else
{
// Insert right after the _class line.
result = Regex.Replace(
result,
@"(_class\s*=\s*""RenderMeshFile""[^\r\n]*\r?\n)",
m => m.Value + "\t\t\t\t\tname = \"" + lodName + "\"\n"
);
}
// Replace `filename = "..."` with our project-relative path.
result = Regex.Replace( result, @"\bfilename\s*=\s*""[^""]*""", $"filename = \"{filename}\"" );
return result;
}
/// <summary>
/// Replace a RenderMeshFile block's import_filter with permissive defaults so every mesh in
/// the referenced FBX is imported. Used when cloning the bootstrap block for non-LOD0 levels.
/// </summary>
private static string ResetImportFilter( string block )
{
var pattern = new Regex( @"import_filter\s*=\s*\{[^}]*\}", RegexOptions.Singleline );
const string permissive = "import_filter = \n\t\t\t\t\t\t{\n\t\t\t\t\t\t\texclude_by_default = false\n\t\t\t\t\t\t\texception_list = [ ]\n\t\t\t\t\t\t}";
if ( pattern.IsMatch( block ) )
return pattern.Replace( block, permissive );
// No filter present at all — leave the block as-is (engine defaults are already permissive).
return block;
}
/// <summary>
/// Remove every top-level KV3 node whose `_class` equals <paramref name="className"/>, along
/// with its trailing comma and surrounding indentation, so the children array stays well-formed.
/// </summary>
private static string RemoveBlocksByClass( string vmdlText, string className )
{
var pattern = new Regex( $@"_class\s*=\s*""{Regex.Escape( className )}""" );
while ( true )
{
var match = pattern.Match( vmdlText );
if ( !match.Success )
break;
int openIdx = vmdlText.LastIndexOf( '{', match.Index );
if ( openIdx < 0 )
break;
int closeIdx = FindMatchingBrace( vmdlText, openIdx );
if ( closeIdx < 0 )
break;
// Eat any leading indentation on the same line so we don't leave dangling tabs.
int start = openIdx;
while ( start > 0 && (vmdlText[start - 1] == '\t' || vmdlText[start - 1] == ' ') )
start--;
int end = closeIdx + 1;
if ( end < vmdlText.Length && vmdlText[end] == ',' )
end++;
while ( end < vmdlText.Length && (vmdlText[end] == ' ' || vmdlText[end] == '\t') )
end++;
if ( end < vmdlText.Length && vmdlText[end] == '\r' )
end++;
if ( end < vmdlText.Length && vmdlText[end] == '\n' )
end++;
vmdlText = vmdlText.Substring( 0, start ) + vmdlText.Substring( end );
}
return vmdlText;
}
/// <summary>
/// Build the KV3 text for an LODGroupList node with one LODGroup per LOD level.
/// </summary>
private static string BuildLodGroupList( int lodCount )
{
var sb = new StringBuilder();
sb.Append( "{\n" );
sb.Append( "\t\t\t\t_class = \"LODGroupList\"\n" );
sb.Append( "\t\t\t\tchildren = \n" );
sb.Append( "\t\t\t\t[\n" );
for ( int i = 0; i < lodCount; i++ )
{
var threshold = FabLodSettings.GetThreshold( i );
sb.Append( "\t\t\t\t\t{\n" );
sb.Append( "\t\t\t\t\t\t_class = \"LODGroup\"\n" );
sb.Append( "\t\t\t\t\t\tswitch_threshold = " );
sb.Append( threshold.ToString( "F1", CultureInfo.InvariantCulture ) );
sb.Append( "\n" );
sb.Append( "\t\t\t\t\t\tmeshes = \n" );
sb.Append( "\t\t\t\t\t\t[\n" );
sb.Append( "\t\t\t\t\t\t\t\"LOD" );
sb.Append( i );
sb.Append( "\",\n" );
sb.Append( "\t\t\t\t\t\t]\n" );
sb.Append( "\t\t\t\t\t},\n" );
}
sb.Append( "\t\t\t\t]\n" );
sb.Append( "\t\t\t}" );
return sb.ToString();
}
/// <summary>
/// True if the VMDL already declares a PhysicsShapeList — avoids stacking duplicate physics nodes
/// if the bootstrap (or a previous import pass) already inserted one.
/// </summary>
private static bool VmdlHasPhysicsShape( string vmdlText )
{
return Regex.IsMatch( vmdlText, @"_class\s*=\s*""PhysicsShapeList""" );
}
/// <summary>
/// Append a PhysicsShapeList with a single PhysicsHullFromRender child to the rootNode's children
/// array. This is the engine-native way to auto-generate convex-hull collision from the render mesh.
/// </summary>
private static string AddPhysicsShapeList( string vmdlText )
{
// Find the rootNode children array opener so we can append before the closing `]`.
var rootChildrenMatch = Regex.Match( vmdlText, @"_class\s*=\s*""RootNode""[\s\S]*?children\s*=\s*\[" );
if ( !rootChildrenMatch.Success )
{
Log.Warning( "FabBridge: AddPhysicsShapeList - couldn't find rootNode children array" );
return vmdlText;
}
int arrayOpen = rootChildrenMatch.Index + rootChildrenMatch.Length;
int arrayClose = FindMatchingBracket( vmdlText, arrayOpen - 1 );
if ( arrayClose < 0 )
{
Log.Warning( "FabBridge: AddPhysicsShapeList - couldn't locate end of children array" );
return vmdlText;
}
const string physicsBlock =
"{\n" +
"\t\t\t\t_class = \"PhysicsShapeList\"\n" +
"\t\t\t\tchildren = \n" +
"\t\t\t\t[\n" +
"\t\t\t\t\t{\n" +
"\t\t\t\t\t\t_class = \"PhysicsHullFromRender\"\n" +
"\t\t\t\t\t\tparent_bone = \"\"\n" +
"\t\t\t\t\t\tsurface_prop = \"default\"\n" +
"\t\t\t\t\t\tcollision_tags = \"solid\"\n" +
"\t\t\t\t\t\tfaceMergeAngle = 20.0\n" +
"\t\t\t\t\t\tmaxHullVertices = 32\n" +
"\t\t\t\t\t},\n" +
"\t\t\t\t]\n" +
"\t\t\t}";
// Insert just before the closing `]`. The preceding child node always ends with a trailing
// comma in our output, so we don't need to inject one.
return vmdlText.Substring( 0, arrayClose )
+ "\t\t\t" + physicsBlock + ",\n\t\t\t"
+ vmdlText.Substring( arrayClose );
}
/// <summary>
/// Find the matching `]` for a `[` at <paramref name="openIdx"/>, honoring strings and nested braces.
/// </summary>
private static int FindMatchingBracket( string text, int openIdx )
{
int depth = 0;
bool inString = false;
bool escaped = false;
for ( int i = openIdx; i < text.Length; i++ )
{
char c = text[i];
if ( escaped ) { escaped = false; continue; }
if ( c == '\\' && inString ) { escaped = true; continue; }
if ( c == '"' ) { inString = !inString; continue; }
if ( inString ) continue;
if ( c == '[' )
{
depth++;
}
else if ( c == ']' )
{
depth--;
if ( depth == 0 )
return i;
}
}
return -1;
}
/// <summary>
/// Detect Fab quality-tier folders on disk and return an ordered LOD chain.
/// The primary mesh is always kept as LOD0. Lower-detail tiers (relative to the primary's
/// tier) that exist on disk are appended as LOD1, LOD2, ...
/// </summary>
/// <remarks>
/// Fab's UI requires each quality tier to be downloaded separately, leaving a cache layout like:
/// <c>VaultCache/.../fbx/high/<extracted>/Asset_High.fbx</c>
/// <c>VaultCache/.../fbx/medium/<extracted>/Asset_Medium.fbx</c>
/// <c>VaultCache/.../fbx/low/<extracted>/Asset_Low.fbx</c>
/// Any subset may exist. If only medium+low are present, medium becomes LOD0 and low becomes LOD1.
/// "raw" is intentionally skipped — it's source data (e.g. ZBrush .ztl), not a usable LOD.
/// </remarks>
private static List<string> DiscoverQualityTierLods( string primaryMeshPath )
{
var result = new List<string>();
if ( string.IsNullOrEmpty( primaryMeshPath ) )
return result;
result.Add( primaryMeshPath );
// Walk up from the mesh file looking for a folder whose name matches a quality tier.
string current = Path.GetDirectoryName( primaryMeshPath );
int primaryTierIdx = -1;
string tierParent = null;
while ( !string.IsNullOrEmpty( current ) )
{
var name = Path.GetFileName( current )?.ToLowerInvariant();
if ( !string.IsNullOrEmpty( name ) )
{
for ( int gi = 0; gi < QualityTierOrder.Length; gi++ )
{
if ( Array.IndexOf( QualityTierOrder[gi], name ) >= 0 )
{
primaryTierIdx = gi;
tierParent = Path.GetDirectoryName( current );
break;
}
}
if ( primaryTierIdx >= 0 )
break;
}
current = Path.GetDirectoryName( current );
}
if ( primaryTierIdx < 0 || string.IsNullOrEmpty( tierParent ) || !Directory.Exists( tierParent ) )
return result;
// Only append tiers BELOW the primary in detail order — never replace the primary or add higher tiers.
for ( int i = primaryTierIdx + 1; i < QualityTierOrder.Length; i++ )
{
foreach ( var synonym in QualityTierOrder[i] )
{
var tierDir = Path.Combine( tierParent, synonym );
if ( !Directory.Exists( tierDir ) )
continue;
var meshFile = FindFirstMeshFile( tierDir );
if ( !string.IsNullOrEmpty( meshFile ) )
{
Log.Info( $"FabModelConverter: Discovered '{synonym}' tier mesh: {meshFile}" );
result.Add( meshFile );
break; // synonyms — first match in the group wins.
}
}
}
return result;
}
/// <summary>
/// Find the first supported mesh file (FBX preferred, then OBJ/glTF/DAE) under <paramref name="folder"/>.
/// </summary>
private static string FindFirstMeshFile( string folder )
{
if ( !Directory.Exists( folder ) )
return null;
foreach ( var pattern in new[] { "*.fbx", "*.obj", "*.gltf", "*.glb", "*.dae" } )
{
var file = Directory.EnumerateFiles( folder, pattern, SearchOption.AllDirectories ).FirstOrDefault();
if ( !string.IsNullOrEmpty( file ) )
return file;
}
return null;
}
/// <summary>
/// Find the matching closing brace for the '{' at <paramref name="openIdx"/>, honoring strings.
/// </summary>
private static int FindMatchingBrace( string text, int openIdx )
{
int depth = 0;
bool inString = false;
bool escaped = false;
for ( int i = openIdx; i < text.Length; i++ )
{
char c = text[i];
if ( escaped ) { escaped = false; continue; }
if ( c == '\\' && inString ) { escaped = true; continue; }
if ( c == '"' ) { inString = !inString; continue; }
if ( inString )
continue;
if ( c == '{' )
{
depth++;
}
else if ( c == '}' )
{
depth--;
if ( depth == 0 )
return i;
}
}
return -1;
}
}