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.
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;
}
}