Formats/Fbx/FbxMaterialReader.cs
#nullable enable annotations
using System;
using System.Collections.Generic;
using System.Linq;
namespace HumanoidHandRetargeter.Formats.Fbx;
// Material-link parsing reused from the audited legacy editor pipeline.
public static class FbxMaterialReader
{
	public sealed class SourceMaterialInfo
	{
		public string Name { get; init; } = "";
		public string? ColorTexture { get; set; }
		public System.Numerics.Vector3? ColorFactor { get; set; }
		public bool VertexColors { get; set; }
		public string? NormalTexture { get; set; }
		public string? RoughnessTexture { get; set; }
		public string? MetalnessTexture { get; set; }
		public string? OcclusionTexture { get; set; }
		public string? EmissiveTexture { get; set; }
		public string? OpacityTexture { get; set; }
		public bool AlphaTest { get; set; }
		public bool Translucent { get; set; }
		public bool DoubleSided { get; set; }
		public float AlphaCutoff { get; set; } = 0.5f;
	}

	/// <summary>Reads the material→texture links authored in an FBX. Matching through
	/// object IDs makes texture filenames irrelevant (TrumpLPmat → tumpLPcolors.png).</summary>
	public static List<SourceMaterialInfo> Read( byte[] data )
	{
		var root = HumanoidHandRetargeter.Formats.Fbx.FbxTokenizer.Parse( data );
		var objects = root.Child( "Objects" );
		var connections = root.Child( "Connections" );
		var materials = new Dictionary<long, SourceMaterialInfo>();
		var textures = new Dictionary<long, string>();
		var videos = new Dictionary<long, string>();
		var doubleSidedModels = new HashSet<long>();

		if ( objects is not null )
		{
			foreach ( var node in objects.Children )
			{
				if ( node.Properties.Count < 2 || node.Properties[0] is not (long or int)
					|| node.Properties[1] is not string rawName )
					continue;
				var id = node.Prop<long>( 0 );
				if ( node.Name == "Model" && string.Equals(
					node.Child( "Culling" )?.Properties.FirstOrDefault() as string,
					"CullingOff", StringComparison.OrdinalIgnoreCase ) )
					doubleSidedModels.Add( id );
				if ( node.Name == "Material" )
				{
					materials[id] = new SourceMaterialInfo
					{
						Name = HumanoidHandRetargeter.Formats.Fbx.FbxNode.SplitName( rawName ).Name,
						ColorFactor = HumanoidHandRetargeter.Formats.Fbx.FbxMaterialColor.Read( node ),
					};
				}
				else if ( node.Name is "Texture" or "Video" )
				{
					var file = node.Children.FirstOrDefault( child =>
						child.Name.Equals( "RelativeFilename", StringComparison.OrdinalIgnoreCase ) )
						?? node.Children.FirstOrDefault( child =>
							child.Name.Equals( "FileName", StringComparison.OrdinalIgnoreCase )
							|| child.Name.Equals( "Filename", StringComparison.OrdinalIgnoreCase ) );
					if ( file?.Properties.FirstOrDefault() is string path )
					{
						if ( node.Name == "Texture" ) textures[id] = path;
						else videos[id] = path;
					}
				}
			}
		}

		if ( connections is null )
			return materials.Values.ToList();

		var coloredGeometry = objects?.Children.Where( n => n.Name == "Geometry"
			&& HumanoidHandRetargeter.Formats.Fbx.FbxMaterialColor.HasVertexColors( n ) )
			.Select( n => n.Prop<long>( 0 ) ).ToHashSet() ?? new HashSet<long>();
		var coloredModels = connections.ChildrenNamed( "C" )
			.Where( n => n.Properties.Count >= 3 && n.Properties[0] is "OO"
				&& n.Properties[1] is long or int && n.Properties[2] is long or int
				&& coloredGeometry.Contains( n.Prop<long>( 1 ) ) )
			.Select( n => n.Prop<long>( 2 ) ).ToHashSet();

		// Video objects commonly carry the only usable filename and parent a Texture.
		foreach ( var connection in connections.ChildrenNamed( "C" ) )
		{
			if ( connection.Properties.Count < 3 || connection.Properties[0] is not string kind
				|| kind != "OO" || connection.Properties[1] is not (long or int)
				|| connection.Properties[2] is not (long or int) )
				continue;
			var source = connection.Prop<long>( 1 );
			var target = connection.Prop<long>( 2 );
			if ( videos.TryGetValue( source, out var file ) && !textures.ContainsKey( target ) )
				textures[target] = file;
		}

		foreach ( var connection in connections.ChildrenNamed( "C" ) )
		{
			if ( connection.Properties.Count < 3 || connection.Properties[0] is not string kind
				|| connection.Properties[1] is not (long or int)
				|| connection.Properties[2] is not (long or int) )
				continue;
			var source = connection.Prop<long>( 1 );
			var target = connection.Prop<long>( 2 );
			// FBX stores sidedness on the mesh model, not its material.
			if ( kind == "OO" && coloredModels.Contains( target )
				&& materials.TryGetValue( source, out var coloredMaterial ) )
				coloredMaterial.VertexColors = true;
			if ( kind == "OO" && doubleSidedModels.Contains( target )
				&& materials.TryGetValue( source, out var boundMaterial ) )
				boundMaterial.DoubleSided = true;
			if ( !textures.TryGetValue( source, out var file )
				|| !materials.TryGetValue( target, out var material ) )
				continue;
			var channel = kind == "OP" && connection.Properties.Count >= 4
				&& connection.Properties[3] is string property ? property : "DiffuseColor";
			if ( channel.Contains( "transparent", StringComparison.OrdinalIgnoreCase )
				|| channel.Contains( "transparency", StringComparison.OrdinalIgnoreCase )
				|| channel.Contains( "opacity", StringComparison.OrdinalIgnoreCase )
				|| channel.Contains( "alpha", StringComparison.OrdinalIgnoreCase ) )
			{
				material.OpacityTexture ??= file;
				material.Translucent = true;
			}
			else if ( channel.Contains( "normal", StringComparison.OrdinalIgnoreCase )
				|| channel.Contains( "bump", StringComparison.OrdinalIgnoreCase ) )
				material.NormalTexture ??= file;
			else if ( channel.Contains( "rough", StringComparison.OrdinalIgnoreCase )
				|| channel.Contains( "gloss", StringComparison.OrdinalIgnoreCase ) )
				material.RoughnessTexture ??= file;
			else if ( channel.Contains( "metal", StringComparison.OrdinalIgnoreCase ) )
				material.MetalnessTexture ??= file;
			else if ( channel.Contains( "occlusion", StringComparison.OrdinalIgnoreCase )
				|| channel.Contains( "ambient", StringComparison.OrdinalIgnoreCase ) )
				material.OcclusionTexture ??= file;
			else if ( channel.Contains( "emissive", StringComparison.OrdinalIgnoreCase )
				|| channel.Contains( "emission", StringComparison.OrdinalIgnoreCase ) )
				material.EmissiveTexture ??= file;
			else if ( channel.Contains( "diffuse", StringComparison.OrdinalIgnoreCase )
				|| channel.Contains( "color", StringComparison.OrdinalIgnoreCase ) )
				material.ColorTexture ??= file;
		}
		return materials.Values.ToList();
	}

}