Editor/Converters/FabDecalConverter.cs
using System;
using System.IO;
using System.Text;
namespace FabBridge.Converters;
/// <summary>
/// Generates s&box .decal (DecalDefinition) resources from a Fab texture-only asset.
/// Builds a Color+Alpha PNG by combining BaseColor and Opacity (Fab ships them separately),
/// then writes a JSON .decal file referencing it.
/// </summary>
public static class FabDecalConverter
{
public class ConversionResult
{
public bool Success { get; set; }
public string DecalPath { get; set; }
public string RelativePath { get; set; }
public string ColorWithAlphaPath { get; set; }
public string Error { get; set; }
public Asset Asset { get; set; }
}
/// <summary>
/// True if the asset looks like a decal candidate: no mesh data, and has both a color and
/// an opacity texture in the converted results. The opacity map is the strong signal —
/// non-decal tiling materials don't ship one.
/// </summary>
public static bool LooksLikeDecal( FabAsset fabAsset, List<FabTextureConverter.ConversionResult> textureResults )
{
if ( fabAsset == null || textureResults == null )
return false;
var allMeshes = fabAsset.GetAllMeshes();
if ( allMeshes.Count > 0 )
{
Log.Info( $"FabDecalConverter: Not a decal — asset has {allMeshes.Count} mesh(es)" );
return false;
}
var hasMeshComponents = fabAsset.Components?.Any( c => IsMeshFile( c.Path ) ) ?? false;
if ( hasMeshComponents )
{
Log.Info( "FabDecalConverter: Not a decal — asset has mesh components" );
return false;
}
var hasColor = FindColorTexture( textureResults ) != null;
var hasOpacity = FindOpacityTexture( textureResults ) != null;
Log.Info( $"FabDecalConverter: hasColor={hasColor}, hasOpacity={hasOpacity}" );
return hasColor && hasOpacity;
}
/// <summary>
/// Generate a .decal resource for the given Fab asset and its converted textures.
/// Assumes <see cref="LooksLikeDecal"/> already returned true.
/// </summary>
public static ConversionResult Generate( FabAsset fabAsset, List<FabTextureConverter.ConversionResult> textureResults, string destinationFolder )
{
var result = new ConversionResult();
try
{
Directory.CreateDirectory( destinationFolder );
var baseName = fabAsset.GetSafeFileName();
var colorResult = FindColorTexture( textureResults );
var opacityResult = FindOpacityTexture( textureResults );
var normalResult = FindTextureBySuffix( textureResults, "_normal", "normal" );
var heightResult = FindTextureBySuffix( textureResults, "_height", "displacement", "height" );
if ( colorResult == null || opacityResult == null )
{
result.Error = "Missing required color or opacity texture for decal";
return result;
}
// Bake a Color+Alpha PNG that the decal can use directly as ColorTexture.
var colorAlphaPath = Path.Combine( destinationFolder, $"{baseName}_color_alpha.png" );
if ( !BuildColorWithAlpha( colorResult.DestinationPath, opacityResult.DestinationPath, colorAlphaPath ) )
{
result.Error = "Failed to combine color and opacity into RGBA PNG";
return result;
}
result.ColorWithAlphaPath = colorAlphaPath;
// Register so the engine knows about it before the .decal references it.
AssetSystem.RegisterFile( colorAlphaPath );
// Build relative paths for the .decal references.
var colorAlphaRel = FabTextureConverter.GetRelativePath( colorAlphaPath );
var normalRel = normalResult != null ? normalResult.RelativePath : null;
var heightRel = heightResult != null ? heightResult.RelativePath : null;
// Write the .decal file. Format mirrors the simple GameResource serialization used
// by the engine's own decals (see addons/base/Assets/decals/circle.decal).
var decalPath = Path.Combine( destinationFolder, $"{baseName}.decal" );
var json = BuildDecalJson( colorAlphaRel, normalRel, heightRel );
File.WriteAllText( decalPath, json );
// Force a recompile of any stale compiled output.
var compiledPath = decalPath + "_c";
if ( File.Exists( compiledPath ) )
{
try { File.Delete( compiledPath ); }
catch { /* best-effort */ }
}
var decalAsset = AssetSystem.RegisterFile( decalPath ) ?? AssetSystem.FindByPath( decalPath );
if ( decalAsset == null )
{
result.Error = "Failed to register .decal asset";
return result;
}
result.Asset = decalAsset;
result.DecalPath = decalPath;
result.RelativePath = decalAsset.Path;
result.Success = true;
Log.Info( $"FabBridge: Created decal at {decalPath}" );
}
catch ( Exception ex )
{
result.Error = ex.Message;
Log.Error( $"FabBridge: Decal generation failed: {ex.Message}" );
}
return result;
}
/// <summary>
/// Combine an RGB color image and a grayscale opacity image into a single RGBA PNG.
/// If the two source images differ in size, the alpha is sampled at scaled coordinates.
/// </summary>
private static bool BuildColorWithAlpha( string colorPath, string opacityPath, string outputPath )
{
try
{
if ( !File.Exists( colorPath ) || !File.Exists( opacityPath ) )
{
Log.Warning( $"FabBridge: BuildColorWithAlpha - source missing (color: {File.Exists( colorPath )}, opacity: {File.Exists( opacityPath )})" );
return false;
}
using var colorBmp = Bitmap.CreateFromBytes( File.ReadAllBytes( colorPath ) );
using var alphaBmp = Bitmap.CreateFromBytes( File.ReadAllBytes( opacityPath ) );
if ( colorBmp == null || alphaBmp == null )
return false;
int w = colorBmp.Width;
int h = colorBmp.Height;
using var output = new Bitmap( w, h );
var colorPixels = colorBmp.GetPixels32();
var alphaPixels = alphaBmp.GetPixels32();
int aw = alphaBmp.Width;
int ah = alphaBmp.Height;
bool sameSize = aw == w && ah == h;
var result = new Color32[w * h];
for ( int y = 0; y < h; y++ )
{
for ( int x = 0; x < w; x++ )
{
int ci = y * w + x;
var c = colorPixels[ci];
byte a;
if ( sameSize )
{
a = alphaPixels[ci].r;
}
else
{
// Nearest-neighbor sample for mismatched sizes; rare for Fab but handle it.
int ax = Math.Min( x * aw / w, aw - 1 );
int ay = Math.Min( y * ah / h, ah - 1 );
a = alphaPixels[ay * aw + ax].r;
}
result[ci] = new Color32( c.r, c.g, c.b, a );
}
}
// Write back via SetPixels then encode.
var asColors = new Color[result.Length];
for ( int i = 0; i < result.Length; i++ )
{
var p = result[i];
asColors[i] = new Color( p.r / 255f, p.g / 255f, p.b / 255f, p.a / 255f );
}
output.SetPixels( asColors );
File.WriteAllBytes( outputPath, output.ToPng() );
Log.Info( $"FabBridge: Wrote {w}x{h} color+alpha PNG to {outputPath}" );
return true;
}
catch ( Exception ex )
{
Log.Error( $"FabBridge: BuildColorWithAlpha failed: {ex.Message}" );
return false;
}
}
private static string BuildDecalJson( string colorRel, string normalRel, string heightRel )
{
// Engine accepts either a plain string path or an inline imagefile compiler block for
// each texture field. The string form is simpler and what circle.decal uses.
var sb = new StringBuilder();
sb.Append( "{\n" );
sb.Append( $" \"ColorTexture\": \"{colorRel}\",\n" );
sb.Append( normalRel != null ? $" \"NormalTexture\": \"{normalRel}\",\n" : " \"NormalTexture\": null,\n" );
sb.Append( " \"RoughMetalOcclusionTexture\": null,\n" );
sb.Append( " \"EmissiveTexture\": null,\n" );
sb.Append( " \"EmissionEnergy\": 1,\n" );
sb.Append( heightRel != null ? $" \"HeightTexture\": \"{heightRel}\",\n" : " \"HeightTexture\": null,\n" );
sb.Append( " \"ParallaxStrength\": 1,\n" );
sb.Append( " \"Tint\": \"1,1,1,1\",\n" );
sb.Append( " \"ColorMix\": 1,\n" );
sb.Append( " \"Width\": 64,\n" );
sb.Append( " \"Height\": 64,\n" );
sb.Append( " \"FilterMode\": \"Anisotropic\",\n" );
sb.Append( " \"__references\": [],\n" );
sb.Append( " \"__version\": 0\n" );
sb.Append( "}\n" );
return sb.ToString();
}
/// <summary>
/// Find a converted texture by matching its filename against the given keywords.
/// FabTextureConverter renames files with the s&box-style suffix (e.g. "_color", "_normal",
/// "_opacity"), so the keywords should include both the suffix form and the raw Fab type name.
/// </summary>
private static FabTextureConverter.ConversionResult FindTextureBySuffix( List<FabTextureConverter.ConversionResult> results, params string[] keywords )
{
foreach ( var r in results )
{
if ( r == null || !r.Success || string.IsNullOrEmpty( r.DestinationPath ) )
continue;
var name = Path.GetFileNameWithoutExtension( r.DestinationPath )?.ToLowerInvariant() ?? "";
foreach ( var kw in keywords )
{
if ( name.Contains( kw ) )
return r;
}
}
return null;
}
private static FabTextureConverter.ConversionResult FindColorTexture( List<FabTextureConverter.ConversionResult> results )
{
// "_color" is the s&box suffix; "basecolor"/"albedo"/"diffuse" are raw Fab type names that may
// also appear in the filename. Excluding "_color_alpha" so a baked decal output isn't picked.
foreach ( var r in results )
{
if ( r == null || !r.Success || string.IsNullOrEmpty( r.DestinationPath ) )
continue;
var name = Path.GetFileNameWithoutExtension( r.DestinationPath )?.ToLowerInvariant() ?? "";
if ( name.Contains( "_color_alpha" ) )
continue;
if ( name.Contains( "_color" ) || name.Contains( "basecolor" ) || name.Contains( "albedo" ) || name.Contains( "diffuse" ) )
return r;
}
return null;
}
private static FabTextureConverter.ConversionResult FindOpacityTexture( List<FabTextureConverter.ConversionResult> results )
{
return FindTextureBySuffix( results, "_opacity", "opacity", "alpha" );
}
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";
}
}