Code/AutoRig/Analyze/MeshClassifier.cs

Classifier for RigMesh objects that decides whether a mesh is Organic, Mechanical or Hybrid. It combines signals from part count, surface smoothness (dihedral angles), name hints, and dominant-part area ratio, and returns a Classification with kind, confidence and explanation.

File Access
using System.Numerics;
using AutoRig.Mesh;

namespace AutoRig.Analyze;

// s&box compat: the engine defines Vector2/Vector3 in the GLOBAL namespace, which
// shadows using-directive imports - alias explicitly to System.Numerics.
using Vector2 = System.Numerics.Vector2;
using Vector3 = System.Numerics.Vector3;


/// <summary>
/// Organic / mechanical / hybrid classification from four signals: part count,
/// surface smoothness (dihedral angles), name hints, and dominant-part area ratio.
/// Hybrid is a per-part override: a smooth dominant body carrying several rigid
/// attachments (weighted-score gaps alone never flag that case — the big organic
/// part outweighs its small rigid attachments).
/// </summary>
public static class MeshClassifier
{
    const float SmoothDihedralDegrees = 30f;

    public static Classification Classify(
        RigMesh mesh, IReadOnlyList<MeshPart> parts, SymmetryPlane? symmetry )
    {
        ArgumentNullException.ThrowIfNull( mesh );
        ArgumentNullException.ThrowIfNull( parts );

        if ( parts.Count == 0 || mesh.TriangleCount == 0 )
        {
            return new Classification
            {
                Kind = MeshKind.Organic,
                Confidence = 0.05f,
                Explanation = "Empty or degenerate mesh - defaulting to a single-bone organic rig.",
            };
        }

        // ---- signal: part count ----
        var partCountOrganic = parts.Count switch
        {
            1 => 0.9f,
            >= 5 => 0.1f,
            _ => 0.9f - (parts.Count - 1) / 4f * 0.8f,
        };

        // ---- signal: smoothness (global + per part) ----
        var weld = MeshSegmenter.WeldMap( mesh, 0f );
        var partSmoothness = new float[parts.Count];
        float smoothEdges = 0, totalEdges = 0;
        for ( var i = 0; i < parts.Count; i++ )
        {
            var (smooth, total) = SmoothEdgeCounts( mesh, weld, parts[i] );
            partSmoothness[i] = total > 0 ? (float)smooth / total : 1f;
            smoothEdges += smooth;
            totalEdges += total;
        }
        var smoothness = totalEdges > 0 ? smoothEdges / totalEdges : 1f;

        // ---- signal: names ----
        var names = parts.Select( p => p.Name ).Concat( mesh.Tags ).ToArray();
        var mechanicalNames = NameHints.MechanicalScore( names );
        var organicNames = NameHints.OrganicScore( names );

        // ---- signal: dominance ----
        var totalArea = parts.Sum( p => p.SurfaceArea );
        var dominance = totalArea > 0 ? parts[0].SurfaceArea / totalArea : 1f;

        // Smoothness is a weak discriminator on finely-tessellated CAD (machined curves
        // have small dihedrals too), so part structure and names carry more weight.
        var organic = 0.40f * partCountOrganic
            + 0.20f * smoothness
            + 0.30f * organicNames
            + 0.10f * dominance;
        var mechanical = 0.40f * (1f - partCountOrganic)
            + 0.20f * (1f - smoothness)
            + 0.30f * mechanicalNames
            + 0.10f * (1f - dominance);

        // ---- hard rule: a swarm of parts with no dominant body is a machine ----
        // No organic deforming creature is 8+ loose pieces none of which dominates;
        // organic naming evidence (hair/eye/body parts on characters) vetoes the rule.
        if ( parts.Count >= 8 && dominance < 0.7f && organicNames <= mechanicalNames )
        {
            return new Classification
            {
                Kind = MeshKind.Mechanical,
                Confidence = 0.9f,
                Explanation = $"{parts.Count} rigid part(s) with no dominant body - treating as a machine.",
            };
        }

        // ---- hybrid override: smooth dominant body + several rigid attachments ----
        var rigidAttachments = 0;
        for ( var i = 1; i < parts.Count; i++ )
            if ( partSmoothness[i] < 0.7f )
                rigidAttachments++;
        var isHybrid = parts.Count >= 3
            && partSmoothness[0] >= 0.7f
            && rigidAttachments >= 2
            && dominance is >= 0.5f and <= 0.97f;

        var gap = MathF.Abs( organic - mechanical );
        if ( isHybrid )
        {
            return new Classification
            {
                Kind = MeshKind.Hybrid,
                Confidence = Math.Clamp( 0.4f + 0.2f * rigidAttachments / (parts.Count - 1f), 0.05f, 0.99f ),
                Explanation = $"Smooth main body with {rigidAttachments} rigid attachment(s) - mixed rig.",
            };
        }

        if ( organic >= mechanical )
        {
            return new Classification
            {
                Kind = MeshKind.Organic,
                Confidence = Math.Clamp( gap, 0.05f, 0.99f ),
                Explanation = parts.Count == 1
                    ? "One smooth connected surface - treating as a creature/character."
                    : $"{parts.Count} part(s), mostly smooth surfaces - treating as a creature/character.",
            };
        }

        var namedHint = mechanicalNames > 0
            ? $" with mechanical names ({FirstMechanicalNames( names )})"
            : "";
        return new Classification
        {
            Kind = MeshKind.Mechanical,
            Confidence = Math.Clamp( gap, 0.05f, 0.99f ),
            Explanation = $"{parts.Count} rigid part(s){namedHint} - treating as a machine.",
        };
    }

    static string FirstMechanicalNames( string[] names )
    {
        var hits = names
            .Where( n => NameHints.MechanicalScore( [ n ] ) > 0 )
            .Take( 2 )
            .Select( n => n.ToLowerInvariant() );
        return string.Join( ", ", hits );
    }

    /// <summary>
    /// Counts (smooth, total) interior edges of a part. An interior edge is shared by
    /// exactly two of the part's triangles under welded vertex identity; smooth means
    /// the dihedral angle between face normals is under 30°. Capped at 5000 edges by
    /// deterministic stride.
    /// </summary>
    static (int Smooth, int Total) SmoothEdgeCounts( RigMesh mesh, int[] weld, MeshPart part )
    {
        var edges = new Dictionary<(int A, int B), (int Tri, bool Used)>();
        var pairs = new List<(int TriA, int TriB)>();

        foreach ( var tri in part.TriangleIndices )
        {
            for ( var k = 0; k < 3; k++ )
            {
                var a = weld[mesh.Triangles[tri * 3 + k]];
                var b = weld[mesh.Triangles[tri * 3 + (k + 1) % 3]];
                if ( a == b )
                    continue; // degenerate edge
                var key = a < b ? (a, b) : (b, a);
                if ( edges.TryGetValue( key, out var existing ) )
                {
                    if ( !existing.Used )
                    {
                        pairs.Add( (existing.Tri, tri) );
                        edges[key] = (existing.Tri, true);
                    }
                }
                else
                {
                    edges[key] = (tri, false);
                }
            }
        }

        var stride = Math.Max( 1, pairs.Count / 5000 );
        var smooth = 0;
        var total = 0;
        var cosThreshold = MathF.Cos( SmoothDihedralDegrees * MathF.PI / 180f );
        for ( var i = 0; i < pairs.Count; i += stride )
        {
            var (ta, tb) = pairs[i];
            var na = FaceNormal( mesh, ta );
            var nb = FaceNormal( mesh, tb );
            if ( na is null || nb is null )
                continue;
            total++;
            if ( Vector3.Dot( na.Value, nb.Value ) >= cosThreshold )
                smooth++;
        }
        return (smooth, total);
    }

    static Vector3? FaceNormal( RigMesh mesh, int tri )
    {
        var a = mesh.Positions[mesh.Triangles[tri * 3]];
        var b = mesh.Positions[mesh.Triangles[tri * 3 + 1]];
        var c = mesh.Positions[mesh.Triangles[tri * 3 + 2]];
        var n = Vector3.Cross( b - a, c - a );
        var length = n.Length();
        return length < 1e-12f ? null : n / length;
    }
}