Editor/FabImportHandler.cs
using System;
using System.IO;
using FabBridge.Converters;
namespace FabBridge;
/// <summary>
/// Handles the complete import process for Fab assets
/// </summary>
public class FabImportHandler
{
/// <summary>
/// Result of a complete asset import
/// </summary>
public class ImportResult
{
public bool Success { get; set; }
public string AssetName { get; set; }
public List<FabTextureConverter.ConversionResult> TextureResults { get; set; } = new();
public List<FabModelConverter.ConversionResult> ModelResults { get; set; } = new();
public FabMaterialConverter.ConversionResult MaterialResult { get; set; }
public FabDecalConverter.ConversionResult DecalResult { get; set; }
public List<string> Errors { get; set; } = new();
public DateTime ImportTime { get; set; }
}
/// <summary>
/// Event raised when an import starts
/// </summary>
public event Action<FabAsset> OnImportStarted;
/// <summary>
/// Event raised when an import completes
/// </summary>
public event Action<ImportResult> OnImportCompleted;
/// <summary>
/// Event raised for progress updates
/// </summary>
public event Action<string> OnProgressUpdate;
/// <summary>
/// The base folder for imported assets (relative to project assets)
/// </summary>
public string ImportFolder { get; set; } = "fab_imports";
/// <summary>
/// Whether to automatically create materials
/// </summary>
public bool CreateMaterials { get; set; } = true;
/// <summary>
/// Whether to automatically convert models
/// </summary>
public bool ConvertModels { get; set; } = true;
/// <summary>
/// Whether the converter should append a PhysicsHullFromRender block to each generated VMDL.
/// Convex-hull collision is the standard engine-generated collision for props/rocks.
/// </summary>
public bool GenerateCollisions { get; set; } = true;
/// <summary>
/// Whether to scan Fab's sibling quality-tier folders (high / mid / low) for additional LOD
/// meshes and bundle them into the VMDL. When off, only the primary mesh that Bridge sent is used.
/// </summary>
public bool DetectLods { get; set; } = true;
/// <summary>
/// Import all assets from an export data package
/// </summary>
public async Task<List<ImportResult>> ImportAllAsync( FabExportData exportData )
{
var results = new List<ImportResult>();
foreach ( var asset in exportData.Assets )
{
var result = await ImportAssetAsync( asset );
results.Add( result );
}
return results;
}
/// <summary>
/// Import a single Fab asset
/// </summary>
public async Task<ImportResult> ImportAssetAsync( FabAsset fabAsset )
{
var result = new ImportResult
{
AssetName = fabAsset.Name ?? fabAsset.Id ?? "Unknown",
ImportTime = DateTime.Now
};
// Debug logging for the asset
Log.Info( $"FabBridge: === Starting import ===" );
Log.Info( $"FabBridge: Asset ID: {fabAsset.Id}" );
Log.Info( $"FabBridge: Asset Name: {fabAsset.Name}" );
Log.Info( $"FabBridge: Display Name: {fabAsset.GetDisplayName()}" );
Log.Info( $"FabBridge: Asset Type: {fabAsset.Type}" );
Log.Info( $"FabBridge: Asset Category: {fabAsset.Category}" );
Log.Info( $"FabBridge: Asset Path: {fabAsset.Path}" );
// Use the new aggregated methods
var allMeshes = fabAsset.GetAllMeshes();
var allTextures = fabAsset.GetAllTextures();
Log.Info( $"FabBridge: Total meshes (from GetAllMeshes): {allMeshes.Count}" );
Log.Info( $"FabBridge: Total textures (from GetAllTextures): {allTextures.Count}" );
Log.Info( $"FabBridge: Meshes array count: {fabAsset.Meshes?.Count ?? 0}" );
Log.Info( $"FabBridge: MeshList array count: {fabAsset.MeshList?.Count ?? 0}" );
Log.Info( $"FabBridge: Materials array count: {fabAsset.Materials?.Count ?? 0}" );
Log.Info( $"FabBridge: Components count: {fabAsset.Components?.Count ?? 0}" );
// Log mesh details
foreach ( var mesh in allMeshes )
{
Log.Info( $"FabBridge: Mesh - Name: {mesh.Name}, File: {mesh.GetFilePath()}" );
}
// Log texture details
foreach ( var tex in allTextures )
{
Log.Info( $"FabBridge: Texture - Type: {tex.Type}, Path: {tex.Path}" );
}
// Log materials info
if ( fabAsset.Materials != null )
{
foreach ( var mat in fabAsset.Materials )
{
var textureCount = mat.Textures?.Count ?? 0;
var textureTypes = mat.Textures != null ? string.Join( ", ", mat.Textures.Keys ) : "none";
Log.Info( $"FabBridge: Material - Name: {mat.Name}, Textures: {textureCount} ({textureTypes})" );
}
}
try
{
OnImportStarted?.Invoke( fabAsset );
OnProgressUpdate?.Invoke( $"Importing {result.AssetName}..." );
// Get the project assets path
var project = Project.Current;
if ( project == null )
{
Log.Error( "FabBridge: No project loaded!" );
result.Errors.Add( "No project loaded" );
return result;
}
var assetsPath = project.GetAssetsPath();
Log.Info( $"FabBridge: Project assets path: {assetsPath}" );
var assetBaseName = fabAsset.GetSafeFileName();
Log.Info( $"FabBridge: Safe file name: {assetBaseName}" );
// Resolve a folder unique to this asset id so two different assets with the same
// display name (Fab does this — e.g. multiple "Nordic Beach Rock Formation" entries)
// don't stomp each other. Same asset id reuses the same folder, so re-imports update
// rather than create duplicates.
var importBasePath = ResolveUniqueAssetFolder( Path.Combine( assetsPath, ImportFolder ), assetBaseName, fabAsset.Id );
var materialsPath = Path.Combine( importBasePath, "materials" );
var modelsPath = Path.Combine( importBasePath, "models" );
Log.Info( $"FabBridge: Import base path: {importBasePath}" );
Log.Info( $"FabBridge: Materials path: {materialsPath}" );
Log.Info( $"FabBridge: Models path: {modelsPath}" );
Directory.CreateDirectory( materialsPath );
Directory.CreateDirectory( modelsPath );
Log.Info( "FabBridge: Created directories" );
// Drop a marker so the next import can recognise this folder as belonging to this asset.
WriteAssetIdMarker( importBasePath, fabAsset.Id );
// Step 1: Import textures
OnProgressUpdate?.Invoke( $"Converting textures for {result.AssetName}..." );
await Task.Yield(); // Allow UI to update
Log.Info( "FabBridge: Starting texture conversion..." );
result.TextureResults = FabTextureConverter.ConvertAllTextures( fabAsset, materialsPath );
Log.Info( $"FabBridge: Texture conversion complete. Results: {result.TextureResults.Count}" );
foreach ( var texResult in result.TextureResults )
{
Log.Info( $"FabBridge: Texture result - Success: {texResult.Success}, Path: {texResult.DestinationPath}, Error: {texResult.Error}" );
if ( !texResult.Success && !string.IsNullOrEmpty( texResult.Error ) )
{
result.Errors.Add( $"Texture: {texResult.Error}" );
}
}
// Decide once whether this asset is a decal — texture-only with an opacity mask. A decal
// asset gets a .decal resource and nothing else; a regular asset gets a vmat.
var hasSuccessfulTextures = result.TextureResults.Any( t => t.Success );
var isDecal = hasSuccessfulTextures && FabDecalConverter.LooksLikeDecal( fabAsset, result.TextureResults );
Log.Info( $"FabBridge: hasSuccessfulTextures={hasSuccessfulTextures}, isDecal={isDecal}, CreateMaterials={CreateMaterials}" );
if ( hasSuccessfulTextures )
{
// Wait for textures to finish compiling BEFORE writing the vmat/decal. If we don't,
// s&box compiles the dependent first, discovers its texture deps are out of date,
// and spams on-demand recompiles for both.
var pendingTextureAssets = result.TextureResults
.Where( t => t.Success && t.Asset != null )
.Select( t => t.Asset )
.ToList();
if ( pendingTextureAssets.Count > 0 )
{
OnProgressUpdate?.Invoke( $"Compiling {pendingTextureAssets.Count} texture(s)..." );
Log.Info( $"FabBridge: Waiting for {pendingTextureAssets.Count} texture(s) to compile..." );
foreach ( var texAsset in pendingTextureAssets )
{
try { await texAsset.CompileIfNeededAsync(); }
catch ( Exception compileEx ) { Log.Warning( $"FabBridge: Texture compile failed for {texAsset.Path}: {compileEx.Message}" ); }
}
await MainThread.Wait();
Log.Info( "FabBridge: Texture compilation complete" );
}
}
if ( isDecal )
{
OnProgressUpdate?.Invoke( $"Creating decal for {result.AssetName}..." );
await Task.Yield();
Log.Info( "FabBridge: Asset detected as a decal — generating .decal resource" );
result.DecalResult = FabDecalConverter.Generate( fabAsset, result.TextureResults, materialsPath );
Log.Info( $"FabBridge: Decal result - Success: {result.DecalResult?.Success}, Path: {result.DecalResult?.DecalPath}, Error: {result.DecalResult?.Error}" );
if ( !result.DecalResult.Success && !string.IsNullOrEmpty( result.DecalResult.Error ) )
result.Errors.Add( $"Decal: {result.DecalResult.Error}" );
}
else if ( CreateMaterials && hasSuccessfulTextures )
{
OnProgressUpdate?.Invoke( $"Creating material for {result.AssetName}..." );
await Task.Yield();
Log.Info( "FabBridge: Starting material generation..." );
result.MaterialResult = FabMaterialConverter.GenerateFromFabAsset(
fabAsset,
result.TextureResults,
materialsPath
);
Log.Info( $"FabBridge: Material result - Success: {result.MaterialResult?.Success}, Path: {result.MaterialResult?.VmatPath}, Error: {result.MaterialResult?.Error}" );
if ( !result.MaterialResult.Success && !string.IsNullOrEmpty( result.MaterialResult.Error ) )
{
result.Errors.Add( $"Material: {result.MaterialResult.Error}" );
}
}
// Step 3: Import models
var meshesToConvert = fabAsset.GetAllMeshes();
var hasMeshes = meshesToConvert.Count > 0;
var hasLods = fabAsset.LodList?.Count > 0;
var hasMeshComponents = fabAsset.Components?.Any( c => IsMeshFile( c.Path ) ) ?? false;
Log.Info( $"FabBridge: ConvertModels={ConvertModels}, hasMeshes={hasMeshes} (count={meshesToConvert.Count}), hasLods={hasLods}, hasMeshComponents={hasMeshComponents}" );
if ( ConvertModels && (hasMeshes || hasLods || hasMeshComponents) )
{
OnProgressUpdate?.Invoke( $"Converting models for {result.AssetName}..." );
await Task.Yield();
Log.Info( "FabBridge: Starting model conversion..." );
result.ModelResults = FabModelConverter.ConvertAllMeshes( fabAsset, modelsPath, GenerateCollisions, DetectLods );
Log.Info( $"FabBridge: Model conversion complete. Results: {result.ModelResults.Count}" );
foreach ( var modelResult in result.ModelResults )
{
Log.Info( $"FabBridge: Model result - Success: {modelResult.Success}, Path: {modelResult.VmdlPath}, Error: {modelResult.Error}" );
if ( !modelResult.Success && !string.IsNullOrEmpty( modelResult.Error ) )
{
result.Errors.Add( $"Model: {modelResult.Error}" );
}
}
// Step 4: Set default material on models
if ( result.MaterialResult?.Success == true && result.ModelResults.Any( m => m.Success ) )
{
OnProgressUpdate?.Invoke( $"Assigning material to models..." );
await Task.Yield();
Log.Info( "FabBridge: Assigning default material to models..." );
foreach ( var modelResult in result.ModelResults.Where( m => m.Success ) )
{
var materialSet = FabModelConverter.SetDefaultMaterial( modelResult, result.MaterialResult );
Log.Info( $"FabBridge: Set material on {modelResult.VmdlPath}: {materialSet}" );
}
}
}
// Determine overall success
result.Success = result.TextureResults.Any( t => t.Success ) ||
result.ModelResults.Any( m => m.Success ) ||
(result.MaterialResult?.Success ?? false) ||
(result.DecalResult?.Success ?? false);
Log.Info( $"FabBridge: === Import complete ===" );
Log.Info( $"FabBridge: Success: {result.Success}" );
Log.Info( $"FabBridge: Errors: {string.Join( ", ", result.Errors )}" );
OnProgressUpdate?.Invoke( result.Success
? $"Successfully imported {result.AssetName}"
: $"Failed to import {result.AssetName}" );
}
catch ( Exception ex )
{
result.Errors.Add( ex.Message );
Log.Error( $"FabBridge: Import failed for {result.AssetName}: {ex.Message}" );
Log.Error( $"FabBridge: Stack trace: {ex.StackTrace}" );
}
OnImportCompleted?.Invoke( result );
return result;
}
/// <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>Marker file written into each asset folder to identify which Fab asset id owns it.</summary>
private const string AssetIdMarkerName = ".fab_asset_id";
/// <summary>
/// Pick a folder under <paramref name="parentPath"/> for this asset. Tries the clean
/// `{baseName}` first, then `{baseName}_{sanitisedId}`, then numeric suffixes — skipping any
/// folder that already belongs to a different Fab asset (per its <see cref="AssetIdMarkerName"/>
/// marker). A folder whose marker matches this asset's id is reused so re-imports update in place.
/// </summary>
private static string ResolveUniqueAssetFolder( string parentPath, string baseName, string assetId )
{
var candidate = Path.Combine( parentPath, baseName );
if ( IsAvailableOrSameAsset( candidate, assetId ) )
return candidate;
if ( !string.IsNullOrEmpty( assetId ) )
{
var safeId = SanitizeIdForFolder( assetId );
if ( !string.IsNullOrEmpty( safeId ) )
{
candidate = Path.Combine( parentPath, $"{baseName}_{safeId}" );
if ( IsAvailableOrSameAsset( candidate, assetId ) )
return candidate;
}
}
// Last resort: numeric suffix. Stops well below int.MaxValue but high enough that hitting
// the cap means something is very wrong.
for ( int i = 2; i < 1000; i++ )
{
candidate = Path.Combine( parentPath, $"{baseName}_{i}" );
if ( IsAvailableOrSameAsset( candidate, assetId ) )
return candidate;
}
// Should never happen in practice — fall back to a guid-tail to guarantee uniqueness.
var fallback = Path.Combine( parentPath, $"{baseName}_{Guid.NewGuid():N}".Substring( 0, parentPath.Length + baseName.Length + 9 ) );
Log.Warning( $"FabBridge: Exhausted disambiguation slots, falling back to {fallback}" );
return fallback;
}
/// <summary>
/// True if the folder doesn't exist yet, or it exists and its marker says it already belongs to
/// <paramref name="assetId"/>. A pre-existing folder with no marker is treated as foreign — we
/// won't write into it.
/// </summary>
private static bool IsAvailableOrSameAsset( string folder, string assetId )
{
if ( !Directory.Exists( folder ) )
return true;
var marker = Path.Combine( folder, AssetIdMarkerName );
if ( !File.Exists( marker ) )
return false;
try
{
var existing = File.ReadAllText( marker ).Trim();
return !string.IsNullOrEmpty( existing ) && existing == (assetId ?? "");
}
catch
{
return false;
}
}
/// <summary>
/// Sanitise an asset id into something safe for a folder suffix. Keeps it short so the path
/// doesn't blow up Windows' MAX_PATH limits on deeply nested assets.
/// </summary>
private static string SanitizeIdForFolder( string id )
{
if ( string.IsNullOrEmpty( id ) )
return "";
var result = id.ToLowerInvariant();
foreach ( var c in Path.GetInvalidFileNameChars() )
result = result.Replace( c, '_' );
result = result.Replace( ' ', '_' ).Replace( '-', '_' );
// Cap length — Fab ids are sometimes long GUIDs and we just need enough to disambiguate.
if ( result.Length > 12 )
result = result.Substring( 0, 12 );
return result;
}
private static void WriteAssetIdMarker( string folder, string assetId )
{
try
{
var marker = Path.Combine( folder, AssetIdMarkerName );
File.WriteAllText( marker, assetId ?? "" );
}
catch ( Exception ex )
{
Log.Warning( $"FabBridge: Failed to write asset id marker: {ex.Message}" );
}
}
}