Editor/Core/UnityFbxBindings.cs

Parser and binder for ASCII FBX files, extracting node/material lists, parent-child connections, material-to-polygon ordering, node transform properties, control-point bounds, and mapping those into a UnityModel (renderer and mesh ids, transforms and bounds). It supports legacy FBX id strings and computes Unity-style mesh ordering and file axis basis.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace ImportUnityPackage;

/// <summary>FBX object hierarchy and ordered material-to-model connections; no vertex decoding needed.</summary>
internal sealed class UnityFbxBindings
{
	internal readonly Dictionary<long, (string Name, bool Mesh)> Nodes = new();
	internal readonly Dictionary<long, string> Materials = new();
	internal readonly List<(long Child, long Parent)> Connections = new();
	/// <summary>Per geometry (or legacy mesh model): the node's material indices in the order its polygons first use them.</summary>
	internal readonly Dictionary<long, List<int>> MaterialOrder = new();
	/// <summary>Per model node: transform properties (Lcl Translation, PreRotation, RotationOrder...) in file units and degrees.</summary>
	internal readonly Dictionary<long, Dictionary<string, double[]>> NodeProperties = new();
	/// <summary>Per geometry (or legacy mesh model): the bounds of its control points, in its own space and file units.</summary>
	internal readonly Dictionary<long, (double[] Min, double[] Max)> VertexBounds = new();
	/// <summary>GlobalSettings axis system (UpAxis, UpAxisSign, FrontAxis...).</summary>
	internal readonly Dictionary<string, double> Axes = new( StringComparer.Ordinal );
	internal static readonly string[] TransformProperties = { "Lcl Translation", "Lcl Rotation", "Lcl Scaling", "PreRotation", "PostRotation", "RotationOrder",
		"RotationPivot", "RotationOffset", "ScalingPivot", "ScalingOffset", "GeometricTranslation", "GeometricRotation", "GeometricScaling" };
	internal static readonly string[] AxisProperties = { "UpAxis", "UpAxisSign", "FrontAxis", "FrontAxisSign", "CoordAxis", "CoordAxisSign" };
	internal void Property( long node, string name, double[] values )
	{
		if ( values.Length == 0 ) return;
		if ( Array.IndexOf( AxisProperties, name ) >= 0 ) Axes[name] = values[0];
		else if ( node != 0 && Array.IndexOf( TransformProperties, name ) >= 0 )
		{
			if ( !NodeProperties.TryGetValue( node, out var properties ) ) NodeProperties[node] = properties = new( StringComparer.Ordinal );
			properties[name] = values;
		}
	}
	readonly Dictionary<string, long> legacyIds = new( StringComparer.Ordinal );
	long asciiModel;
	long asciiOwner;
	bool asciiLayer;
	List<int> asciiOrder;

	/// <summary>Records the first use of each material index in a polygon material array.</summary>
	internal static void FirstUse( List<int> order, IEnumerable<int> indices )
	{
		foreach ( var index in indices )
			if ( !order.Contains( index ) ) order.Add( index );
	}
	long Id( string value )
	{
		if ( long.TryParse( value, out var id ) ) return id;
		if ( value == "Model::Scene" ) return 0;
		if ( !legacyIds.TryGetValue( value, out id ) ) legacyIds[value] = id = -legacyIds.Count - 1;
		return id;
	}
	internal void ObserveAscii( string line )
	{
		var item = Regex.Match( line, "^\\s*(Model|Material):\\s*(?:([-0-9]+),\\s*)?\"(?:Model|Material)::([^\"]+)\",\\s*\"([^\"]*)\"" );
		if ( item.Success )
		{
			var kind = item.Groups[1].Value;
			var id = Id( item.Groups[2].Success ? item.Groups[2].Value : kind + "::" + item.Groups[3].Value );
			if ( kind == "Material" ) { Materials[id] = item.Groups[3].Value; asciiModel = 0; }
			else { Nodes[id] = (item.Groups[3].Value, item.Groups[4].Value == "Mesh"); asciiModel = id; }
		}
		else if ( Regex.IsMatch( line, @"^\s*(?:Geometry|NodeAttribute|Texture|Video|Deformer|Pose|Material):" ) ) asciiModel = 0;
		// "P: "Lcl Translation", "Lcl Translation", "", "A",1,2,3" (FBX 7) or "Property: "Lcl Translation", "Lcl Translation", "A+",1,2,3" (FBX 6).
		var property = Regex.Match( line, @"^\s*(?:P|Property):\s*""([^""]+)""(.*)$" );
		if ( property.Success )
		{
			var values = Regex.Matches( Regex.Replace( property.Groups[2].Value, @"""[^""]*""", "" ), @"-?[0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?" )
				.Select( m => double.Parse( m.Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture ) ).ToArray();
			Property( asciiModel, property.Groups[1].Value, values );
		}
		var link = Regex.Match( line, "^\\s*(?:C|Connect):\\s*\"OO\",\\s*(?:\"([^\"]+)\"|([-0-9]+)),\\s*(?:\"([^\"]+)\"|([-0-9]+))" );
		if ( link.Success ) Connections.Add( (Id( link.Groups[1].Success ? link.Groups[1].Value : link.Groups[2].Value ), Id( link.Groups[3].Success ? link.Groups[3].Value : link.Groups[4].Value )) );
		ObserveAsciiMaterialLayer( line, item );
		ObserveAsciiVertices( line );
	}

	// Polygon material indices: "Materials: *N { a: 0,1,... }" (FBX 7) or "Materials: 0,1,..." continued on comma-led lines (FBX 6),
	// inside the first LayerElementMaterial of a Geometry object (FBX 7) or of the mesh model itself (FBX 6).
	void ObserveAsciiMaterialLayer( string line, Match item )
	{
		static IEnumerable<int> Numbers( string text ) => Regex.Matches( Regex.Replace( text, @"\*\d+\s*\{", "" ), "-?[0-9]+" ).Select( m => int.Parse( m.Value ) );
		if ( asciiOrder != null )
		{
			var continued = Regex.Match( line, @"^\s*(?:a:\s*)?(?=[-0-9,])(.*)$" );
			if ( continued.Success ) { FirstUse( asciiOrder, Numbers( continued.Groups[1].Value ) ); return; }
			MaterialOrder[asciiOwner] = asciiOrder;
			asciiOrder = null;
			asciiLayer = false;
		}
		var geometry = Regex.Match( line, "^\\s*Geometry:\\s*([-0-9]+)," );
		if ( geometry.Success ) asciiOwner = Id( geometry.Groups[1].Value );
		else if ( item.Success && item.Groups[1].Value == "Model" && item.Groups[4].Value == "Mesh" )
			asciiOwner = Id( item.Groups[2].Success ? item.Groups[2].Value : "Model::" + item.Groups[3].Value );
		if ( Regex.IsMatch( line, @"^\s*LayerElementMaterial:" ) ) asciiLayer = !MaterialOrder.ContainsKey( asciiOwner );
		var materials = asciiLayer ? Regex.Match( line, @"^\s*Materials:\s*(.*)$" ) : Match.Empty;
		if ( materials.Success ) { asciiOrder = new(); FirstUse( asciiOrder, Numbers( materials.Groups[1].Value ) ); }
	}

	internal void FinishAscii()
	{
		if ( asciiOrder != null ) MaterialOrder[asciiOwner] = asciiOrder;
		asciiOrder = null;
		asciiVertices?.Finish( this );
		asciiVertices = null;
	}

	/// <summary>Accumulates control-point bounds from a flat x, y, z sequence; the first array per owner counts.</summary>
	internal sealed class BoundsBuilder
	{
		readonly long owner;
		readonly double[] min = { double.MaxValue, double.MaxValue, double.MaxValue }, max = { double.MinValue, double.MinValue, double.MinValue };
		int component;
		internal BoundsBuilder( long owner ) { this.owner = owner; }
		internal void Add( double value )
		{
			if ( double.IsFinite( value ) ) { min[component] = Math.Min( min[component], value ); max[component] = Math.Max( max[component], value ); }
			component = (component + 1) % 3;
		}
		internal void Finish( UnityFbxBindings bindings )
		{
			if ( min[0] <= max[0] && min[1] <= max[1] && min[2] <= max[2] ) bindings.VertexBounds.TryAdd( owner, (min, max) );
		}
	}
	BoundsBuilder asciiVertices;

	// Control points: "Vertices: *N { a: x,y,z,... }" (FBX 7, on Geometry) or "Vertices: x,y,z,..." continued on comma-led
	// lines (FBX 6, on the mesh model).
	void ObserveAsciiVertices( string line )
	{
		static IEnumerable<double> Numbers( string text ) => Regex.Matches( Regex.Replace( text, @"\*\d+\s*\{", "" ), @"[-+]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][-+]?[0-9]+)?" )
			.Select( m => double.Parse( m.Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture ) );
		if ( asciiVertices != null )
		{
			var continued = Regex.Match( line, @"^\s*(?:a:\s*)?(?=[-+0-9.,])(.*)$" );
			if ( continued.Success ) { foreach ( var value in Numbers( continued.Groups[1].Value ) ) asciiVertices.Add( value ); return; }
			asciiVertices.Finish( this );
			asciiVertices = null;
		}
		var start = Regex.Match( line, @"^\s*Vertices:\s*(.*)$" );
		if ( !start.Success || asciiOwner == 0 || VertexBounds.ContainsKey( asciiOwner ) ) return;
		asciiVertices = new BoundsBuilder( asciiOwner );
		foreach ( var value in Numbers( start.Groups[1].Value ) ) asciiVertices.Add( value );
	}

	internal void Apply( UnityModel model, bool preserveHierarchy )
	{
		var parents = Connections.Where( c => Nodes.ContainsKey( c.Child ) && (c.Parent == 0 || Nodes.ContainsKey( c.Parent )) )
			.GroupBy( c => c.Child ).Where( g => g.Select( c => c.Parent ).Distinct().Count() == 1 ).ToDictionary( g => g.Key, g => g.First().Parent );
		var roots = Nodes.Keys.Where( id => !parents.TryGetValue( id, out var parent ) || parent == 0 ).ToArray();
		// Unity numbers nodes that share a path in traversal order, assumed here to be file connection order.
		var order = new Dictionary<long, int>();
		for ( var i = 0; i < Connections.Count; i++ ) order.TryAdd( Connections[i].Child, i );
		var complete = true;
		var meshes = new List<(string Path, UnityModel.RendererBinding Binding)>();
		foreach ( var (id, node) in Nodes.Where( p => p.Value.Mesh ).Select( (p, index) => (p, index) )
			.OrderBy( x => order.GetValueOrDefault( x.p.Key, int.MaxValue ) ).ThenBy( x => x.index ).Select( x => x.p ) )
		{
			var names = new List<string>();
			var seen = new HashSet<long>();
			var current = id;
			while ( Nodes.TryGetValue( current, out var ancestor ) && seen.Add( current ) )
			{
				names.Add( !preserveHierarchy && roots.Length == 1 && roots[0] == current ? "root" : ancestor.Name );
				current = parents.GetValueOrDefault( current );
			}
			if ( current != 0 ) { complete = false; continue; } // Cyclic/malformed hierarchy cannot identify a renderer.
			names.Reverse();
			if ( preserveHierarchy || roots.Length != 1 ) names.Insert( 0, "root" );
			var slots = Connections.Where( c => c.Parent == id && Materials.ContainsKey( c.Child ) ).Select( c => Materials[c.Child] ).ToArray();
			// Unity numbers a mesh's submeshes (renderer material slots) in the order its polygons first use each
			// material, and leaves out materials no polygon uses; the connection order is only the FBX material list.
			var firstUse = MaterialOrder.GetValueOrDefault( id ) ?? Connections.Where( c => c.Parent == id && MaterialOrder.ContainsKey( c.Child ) ).Select( c => MaterialOrder[c.Child] ).FirstOrDefault();
			var used = firstUse?.Where( i => i >= 0 && i < slots.Length ).Select( i => slots[i] ).ToArray();
			if ( used is { Length: > 0 } ) slots = used;
			meshes.Add( ("//RootNode/" + string.Join( "/", names ), new( node.Name, slots )) );
		}
		// Node transforms (FBX's TRS with pivots and pre/post rotations) up the hierarchy, for placing single meshes.
		UnityTransform Local( long node )
		{
			if ( !NodeProperties.TryGetValue( node, out var p ) ) return UnityTransform.Identity;
			double[] V( string key, double fallback ) => p.TryGetValue( key, out var v ) && v.Length >= 3 ? v : new[] { fallback, fallback, fallback };
			UnityTransform T( double[] v ) => UnityTransform.Translation( v[0], v[1], v[2] );
			UnityTransform E( double[] v, int order = 0 ) => UnityTransform.Euler( v[0], v[1], v[2], order );
			var order = p.TryGetValue( "RotationOrder", out var o ) && o.Length > 0 ? (int)o[0] : 0;
			double[] rp = V( "RotationPivot", 0 ), sp = V( "ScalingPivot", 0 ), s = V( "Lcl Scaling", 1 );
			return T( V( "Lcl Translation", 0 ) ) * T( V( "RotationOffset", 0 ) ) * T( rp ) * E( V( "PreRotation", 0 ) ) * E( V( "Lcl Rotation", 0 ), order ) *
				E( V( "PostRotation", 0 ) ).Inverse() * T( rp ).Inverse() * T( V( "ScalingOffset", 0 ) ) * T( sp ) * UnityTransform.Scaling( s[0], s[1], s[2] ) * T( sp ).Inverse();
		}
		UnityTransform Geometric( long node )
		{
			if ( !NodeProperties.TryGetValue( node, out var p ) ) return UnityTransform.Identity;
			double[] V( string key, double fallback ) => p.TryGetValue( key, out var v ) && v.Length >= 3 ? v : new[] { fallback, fallback, fallback };
			double[] t = V( "GeometricTranslation", 0 ), r = V( "GeometricRotation", 0 ), s = V( "GeometricScaling", 1 );
			return UnityTransform.Translation( t[0], t[1], t[2] ) * UnityTransform.Euler( r[0], r[1], r[2] ) * UnityTransform.Scaling( s[0], s[1], s[2] );
		}
		UnityTransform Global( long node, int depth = 0 ) =>
			depth > 64 || !parents.TryGetValue( node, out var parent ) || parent == 0 || !Nodes.ContainsKey( parent ) ? Local( node ) : Global( parent, depth + 1 ) * Local( node );
		// Without Preserve Hierarchy, Unity makes a file's only top node the prefab root.
		if ( !preserveHierarchy && roots.Length == 1 ) model.CollapsedRoot = Local( roots[0] );
		foreach ( var group in Nodes.Where( n => n.Value.Mesh ).GroupBy( n => n.Value.Name, StringComparer.Ordinal ) )
		{
			if ( group.Count() != 1 ) continue;
			var id = group.First().Key;
			var global = model.MeshNodes[group.Key] = Global( id );
			// The mesh's bounds in the file's space: its control points' box, with corners through the node transforms.
			var geometry = VertexBounds.ContainsKey( id ) ? id : Connections.Where( c => c.Parent == id && VertexBounds.ContainsKey( c.Child ) ).Select( c => c.Child ).FirstOrDefault();
			if ( !VertexBounds.TryGetValue( geometry, out var box ) ) continue;
			// The geometric transform (an exporter's pivot offset) moves only this node's mesh, not its children.
			var placed = global * Geometric( id );
			var corners = Enumerable.Range( 0, 8 ).Select( i => placed.Point( (i & 1) == 0 ? box.Min[0] : box.Max[0], (i & 2) == 0 ? box.Min[1] : box.Max[1], (i & 4) == 0 ? box.Min[2] : box.Max[2] ) ).ToArray();
			model.MeshBounds[group.Key] = (Enumerable.Range( 0, 3 ).Select( a => corners.Min( c => c[a] ) ).ToArray(), Enumerable.Range( 0, 3 ).Select( a => corners.Max( c => c[a] ) ).ToArray());
		}
		// The file's axis system as a basis change into FBX's default (Y up, Z front, X across, right-handed).
		double Axis( string key, double fallback ) => Axes.TryGetValue( key, out var value ) ? value : fallback;
		int up = (int)Axis( "UpAxis", 1 ), front = (int)Axis( "FrontAxis", 2 ), coord = (int)Axis( "CoordAxis", 0 );
		if ( new[] { up, front, coord }.Distinct().Count() == 3 && new[] { up, front, coord }.All( a => a is >= 0 and <= 2 ) )
		{
			var basis = new double[3, 3];
			basis[0, coord] = Math.Sign( Axis( "CoordAxisSign", 1 ) ) is 0 ? 1 : Math.Sign( Axis( "CoordAxisSign", 1 ) );
			basis[1, up] = Math.Sign( Axis( "UpAxisSign", 1 ) ) is 0 ? 1 : Math.Sign( Axis( "UpAxisSign", 1 ) );
			basis[2, front] = Math.Sign( Axis( "FrontAxisSign", 1 ) ) is 0 ? 1 : Math.Sign( Axis( "FrontAxisSign", 1 ) );
			model.FileAxes = UnityTransform.Linear( basis );
		}
		var collisions = new HashSet<long>();
		bool Register( Dictionary<long, UnityModel.RendererBinding> table, IEnumerable<(string Key, UnityModel.RendererBinding Binding)> items, Func<string, int, IEnumerable<long>> ids )
		{
			var identified = true;
			foreach ( var group in items.GroupBy( m => m.Key ) )
			{
				var bindings = group.Select( m => m.Binding ).ToArray();
				// Same-named objects are only identified when they are interchangeable, so an unverified order cannot change slots.
				if ( bindings.Skip( 1 ).Any( b => !b.Slots.SequenceEqual( bindings[0].Slots ) ) ) { identified = false; continue; }
				for ( var index = 0; index < bindings.Length; index++ )
					foreach ( var id in ids( group.Key, index ) )
					{
						if ( !table.TryAdd( id, bindings[0] ) ) collisions.Add( id );
						if ( bindings.Length > 1 ) model.DuplicateIds.Add( id );
					}
			}
			return identified;
		}
		// Unity creates a SkinnedMeshRenderer instead of a MeshRenderer for skinned or blend-shape meshes.
		var renderersIdentified = Register( model.RendererSlots, meshes, (path, index) => new[] { UnityFileId.Renderer( path, index ), UnityFileId.Renderer( path, index, "SkinnedMeshRenderer" ) } );
		Register( model.ReconstructedMeshes, meshes.Select( m => (m.Binding.Mesh, m.Binding) ), (name, index) => new[] { UnityFileId.Mesh( name, index ) } );
		foreach ( var id in collisions ) { model.RendererSlots.Remove( id ); model.ReconstructedMeshes.Remove( id ); }
		model.RendererIdsComplete = complete && renderersIdentified && collisions.Count == 0 && meshes.Count > 0;
	}
}