HumanoidMocap/Core/Formats/Fbx/FbxPropSurfaces.cs

Parser for FBX geometry that extracts rigidly skinned triangular surfaces for use as 'prop' contact meshes. It builds an FBX scene, walks geometry and skin clusters, validates weights and transforms, groups triangles by bone, converts coordinates using imported axis settings, and returns surfaces plus a skipped-face count.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Threading;
using HumanoidMocap.Core.Motion;
using HumanoidMocap.Core.Skeleton;

namespace HumanoidMocap.Core.Formats.Fbx;
using Vector3 = System.Numerics.Vector3;

/// <summary>Extracts triangulated rigid skin regions. Mixed weights, additive skin,
/// nontriangular faces and independently animated mesh nodes are not guessed.</summary>
public static class FbxPropSurfaces
{
    public sealed record Result(List<PropSurface> Surfaces,int SkippedFaces);
    public static Result Read(byte[] data,SourceScene imported,float scale=1,CancellationToken cancellation=default)
    {
        var tree=FbxTokenizer.Parse(data);var scene=FbxScene.Build(tree);
        var links=tree.Child("Connections")?.ChildrenNamed("C").Where(n=>n.Prop<string>(0)=="OO")
            .Select(n=>(Child:n.Prop<long>(1),Parent:n.Prop<long>(2))).ToArray()??Array.Empty<(long Child,long Parent)>();
        var result=new List<PropSurface>();var skipped=0;var total=0;
        bool Uniform(Vector3 s)=>s.X>0&&Math.Abs(s.X-s.Y)<=s.X*1e-5f&&Math.Abs(s.X-s.Z)<=s.X*1e-5f;
        bool AnimatedScale(FbxObject? node)
        {
            var seen=new HashSet<long>();
            while(node is not null){if(!seen.Add(node.Id))throw new FormatException("Cyclic prop hierarchy.");
                if(scene.Stacks.Any(s=>s.Bindings.ContainsKey((node.Id,"Lcl Scaling")))||!Uniform(scene.GetVector3(node,"Lcl Scaling",Vector3.One)))return true;node=node.ModelParent;}
            return false;
        }
        Matrix4x4 World(FbxObject? node)
        {
            var matrix=Matrix4x4.Identity;var seen=new HashSet<long>();
            while(node is not null){if(!seen.Add(node.Id))throw new FormatException("Cyclic prop hierarchy.");matrix*=FbxTransform.FromModel(scene,node).LocalMatrixDefault();node=node.ModelParent;}
            return matrix;
        }
        IEnumerable<FbxObject> Children(long id)=>links.Where(c=>c.Parent==id&&scene.ObjectsById.ContainsKey(c.Child)).Select(c=>scene.ObjectsById[c.Child]);
        Vector3 ConvertPoint(Vector3 p)
        {
            float C(int i)=>i==0?p.X:i==1?p.Y:p.Z;
            return new Vector3(C(imported.CoordAxis)*imported.CoordAxisSign,C(imported.UpAxis)*imported.UpAxisSign,
                C(imported.FrontAxis)*imported.FrontAxisSign)*((float)scene.UnitScaleFactor*.01f*scale);
        }
        foreach(var geometry in scene.ObjectsById.Values.Where(o=>o.NodeType=="Geometry"&&o.SubClass=="Mesh"))
        {
            cancellation.ThrowIfCancellationRequested();
            var vertices=geometry.Node.Child("Vertices")?.AsDoubleArray(0);var polygon=geometry.Node.Child("PolygonVertexIndex")?.AsIntArray(0);
            if(vertices is null||polygon is null)continue;
            if(Children(geometry.Id).Any(o=>o.SubClass=="BlendShape")){skipped+=polygon.Count(i=>i<0);continue;}
            if(vertices.Length%3!=0||vertices.Length/3>100000||polygon.Length>600000)throw new FormatException("Prop contact mesh is too large or malformed. Use a triangulated low-poly mesh.");
            var count=vertices.Length/3;var assigned=new string?[count];var points=new Vector3[count];var weightSum=new double[count];var influences=new int[count];
            var meshModels=links.Where(c=>c.Child==geometry.Id&&scene.ObjectsById.ContainsKey(c.Parent)).Select(c=>scene.ObjectsById[c.Parent]).Where(o=>o.NodeType=="Model").ToArray();
            if(meshModels.Length!=1){skipped+=polygon.Count(i=>i<0);continue;}
            var mesh=meshModels[0];
            var geometric=Matrix4x4.CreateScale(scene.GetVector3(mesh,"GeometricScaling",Vector3.One))
                *Matrix4x4.CreateFromQuaternion(FbxTransform.EulerDegreesToQuaternion(scene.GetVector3(mesh,"GeometricRotation",Vector3.Zero),0))
                *Matrix4x4.CreateTranslation(scene.GetVector3(mesh,"GeometricTranslation",Vector3.Zero));
            var skins=Children(geometry.Id).Where(o=>o.NodeType=="Deformer"&&o.SubClass=="Skin").ToArray();
            foreach(var skin in skins)foreach(var cluster in Children(skin.Id).Where(o=>o.SubClass=="Cluster"))
            {
                cancellation.ThrowIfCancellationRequested();
                var bone=Children(cluster.Id).SingleOrDefault(o=>o.NodeType=="Model");
                var mode=cluster.Node.Child("Mode");
                var indices=cluster.Node.Child("Indexes")?.AsIntArray(0);var weights=cluster.Node.Child("Weights")?.AsDoubleArray(0);
                // Exporters may retain clusters for unused bones without either array.
                if(indices is null&&weights is null)continue;
                if(indices is null||weights is null||indices.Length!=weights.Length)throw new FormatException("Invalid prop skin weights.");
                var supported=bone is not null&&imported.Skeleton.IndexOf(bone.Name)>=0&&!AnimatedScale(bone)&&(mode is null||mode.Prop<string>(0) is "Normalize" or "TotalOne");
                var meshToBone=ReadMatrix(cluster.Node.Child("Transform"));var link=ReadMatrix(cluster.Node.Child("TransformLink"));
                if(!Matrix4x4.Invert(link,out _))throw new FormatException("Singular prop skin bind matrix.");
                // Serialized FBX Transform already maps mesh-node space into bind-bone
                // space. The SDK's GetTransformMatrix() instead returns mesh bind world.
                // Applying inverse TransformLink again double-transforms the surface.
                // Prepend geometry placement; absorb uniform scale omitted by rigid XForms.
                if(!Matrix4x4.Decompose(link,out var bindScale,out _,out _)||!Uniform(bindScale))supported=false;
                var local=geometric*meshToBone*Matrix4x4.CreateScale(bindScale);
                for(var i=0;i<indices.Length;i++)
                {
                    var v=indices[i];var w=weights[i];if(v<0||v>=count||!double.IsFinite(w)||w<0)throw new FormatException("Invalid prop vertex weight.");
                    if(w<=1e-6)continue;weightSum[v]+=w;influences[v]++;
                    if(supported){assigned[v]=bone!.Name;points[v]=ConvertPoint(Vector3.Transform(new((float)vertices[v*3],(float)vertices[v*3+1],(float)vertices[v*3+2]),local));}
                }
            }
            // An unskinned mesh may be rigidly parented to a known animated bone.
            if(skins.Length==0)
            {
                var transform=geometric;var node=mesh;var supported=true;var visited=new HashSet<long>();
                while(node is not null&&imported.Skeleton.IndexOf(node.Name)<0)
                {
                    if(!visited.Add(node.Id))throw new FormatException("Cyclic prop model hierarchy.");
                    if(scene.Stacks.Any(s=>s.Bindings.Keys.Any(k=>k.ModelId==node.Id))){supported=false;break;}
                    transform*=FbxTransform.FromModel(scene,node).LocalMatrixDefault();node=node.ModelParent;
                }
                if(supported&&node is not null&&!AnimatedScale(node)&&Matrix4x4.Decompose(World(node),out var parentScale,out _,out _)
                    &&Uniform(parentScale))
                {
                    transform*=Matrix4x4.CreateScale(parentScale);
                    for(var v=0;v<count;v++){assigned[v]=node.Name;weightSum[v]=1;influences[v]=1;points[v]=ConvertPoint(Vector3.Transform(new((float)vertices[v*3],(float)vertices[v*3+1],(float)vertices[v*3+2]),transform));}
                }
            }
            var byBone=new Dictionary<string,List<Vector3>>();var face=new List<int>();
            foreach(var index in polygon)
            {
                var v=index<0?-(long)index-1:index;if(v<0||v>=count)throw new FormatException("Invalid prop polygon index.");face.Add((int)v);
                if(index>=0)continue;
                var name=assigned[face[0]];
                if(face.Count==3&&name is not null&&face.All(i=>assigned[i]==name&&influences[i]==1&&Math.Abs(weightSum[i]-1)<1e-5)
                    &&Vector3.Cross(points[face[1]]-points[face[0]],points[face[2]]-points[face[0]]).LengthSquared()>1e-16f)
                {
                    if(total+3>100000)throw new FormatException("Prop contact geometry exceeds 100,000 vertices. Use a simpler mesh.");
                    if(!byBone.TryGetValue(name,out var list))byBone.Add(name,list=new());
                    foreach(var i in face)list.Add(points[i]);total+=3;
                }
                else skipped++;
                face.Clear();
            }
            if(face.Count>0)throw new FormatException("Unterminated prop polygon.");
            foreach(var pair in byBone)result.Add(new(){Bone=pair.Key,Source="FBX rigid triangle surface: "+geometry.Name,
                Vertices=pair.Value.Select(MotionDocument.A).ToArray(),Triangles=Enumerable.Range(0,pair.Value.Count).ToArray()});
        }
        return new(result,skipped);
    }
    static Matrix4x4 ReadMatrix(FbxNode? node)
    {
        var a=node?.AsDoubleArray(0);if(a is null||a.Length!=16||a.Any(x=>!double.IsFinite(x)))throw new FormatException("Missing or invalid prop skin bind matrix.");
        return new((float)a[0],(float)a[1],(float)a[2],(float)a[3],(float)a[4],(float)a[5],(float)a[6],(float)a[7],
            (float)a[8],(float)a[9],(float)a[10],(float)a[11],(float)a[12],(float)a[13],(float)a[14],(float)a[15]);
    }
}