Editor/Effigy/Rig/SkinBinder.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>Which body a run of vertices in a merged mesh came from. See PartStudio.ToMeshWithBodies.</summary>
public readonly struct BodyRange
{
public readonly string BodyId;
public readonly string BodyName;
public readonly int Start;
public readonly int Count;
public BodyRange( string bodyId, string bodyName, int start, int count )
{
BodyId = bodyId;
BodyName = bodyName;
Start = start;
Count = count;
}
public bool Contains( int vertex ) => vertex >= Start && vertex < Start + Count;
public override string ToString() => $"{BodyName} [{Start}..{Start + Count})";
}
/// <summary>
/// Generating skin weights, so nobody has to paint them.
///
/// WHAT THIS IS NOT: heat-diffusion binding. Real heat diffusion solves a Laplacian system over
/// the mesh and is the right long-term answer — it respects the surface, so a weight cannot bleed
/// across a gap that is near in space but far across the mesh. What is here instead is distance to
/// the bone SEGMENT plus optional smoothing over mesh adjacency, which is cheap, has no solver,
/// and is good enough for the hard-surface props this tool is aimed at. The failure case is honest
/// and predictable: two limbs close together in space bleed into each other. That is the day to
/// write the solver.
///
/// THE INTERESTING ONE IS BindBodies. Because the model is parametric, a bone can own a FEATURE
/// rather than a set of vertices — "this cylinder is the forearm". Vertex indices die on every
/// rebuild; a body id does not. So the binding survives a parameter change, and the weights are
/// regenerated rather than invalidated. Blender cannot do this, because it has no history to bind
/// against.
/// </summary>
public static class SkinBinder
{
/// <summary>Each vertex fully weighted to whichever bone segment is nearest. Blocky across
/// joints, and exactly right for a rigid mechanical part like a hinge or a piston.</summary>
public static SkinWeights BindRigid( PolyMesh mesh, Skeleton skeleton )
{
RequireBones( skeleton );
var weights = new SkinWeights();
var segments = Segments( skeleton );
foreach ( var p in mesh.Positions )
{
var best = 0;
var bestDist = float.MaxValue;
for ( var b = 0; b < segments.Length; b++ )
{
var d = DistanceToSegment( p, segments[b].Head, segments[b].Tail );
if ( d < bestDist )
{
bestDist = d;
best = b;
}
}
weights.Vertices.Add( new[] { new BoneWeight( best, 1f ) } );
}
return weights;
}
/// <summary>
/// Inverse-distance weighting against every bone segment, so joints bend smoothly.
///
/// `falloff` is the exponent: 1 is very soft and reaches across the whole model, 4 is nearly
/// rigid. 2 is a reasonable default and matches what most auto-skin tools land on.
/// Contributions below `cutoff` of the strongest are dropped before normalising, which is what
/// stops a bone on the far side of the model owning half a percent of every vertex.
/// </summary>
public static SkinWeights BindSmooth( PolyMesh mesh, Skeleton skeleton, float falloff = 2f, float cutoff = 0.02f )
{
RequireBones( skeleton );
if ( falloff <= 0f )
throw new ArgumentOutOfRangeException( nameof( falloff ), "Falloff must be positive" );
var weights = new SkinWeights();
var segments = Segments( skeleton );
var distances = new float[segments.Length];
foreach ( var p in mesh.Positions )
{
var nearest = float.MaxValue;
for ( var b = 0; b < segments.Length; b++ )
{
distances[b] = DistanceToSegment( p, segments[b].Head, segments[b].Tail );
nearest = MathF.Min( nearest, distances[b] );
}
// A vertex sitting exactly on a bone would divide by zero. Snapping it wholly to that
// bone is both the limit of the formula and the answer anyone would expect.
if ( nearest < 1e-6f )
{
var onBone = Array.IndexOf( distances, nearest );
weights.Vertices.Add( new[] { new BoneWeight( onBone < 0 ? 0 : onBone, 1f ) } );
continue;
}
var acc = new Dictionary<int, float>();
var strongest = 0f;
for ( var b = 0; b < segments.Length; b++ )
{
var influence = 1f / MathF.Pow( distances[b], falloff );
acc[b] = influence;
strongest = MathF.Max( strongest, influence );
}
var floor = strongest * cutoff;
foreach ( var b in new List<int>( acc.Keys ) )
{
if ( acc[b] < floor )
acc.Remove( b );
}
weights.Vertices.Add( SkinWeights.Finish( acc ) );
}
return weights;
}
/// <summary>
/// Bind whole bodies to named bones — the parametric path.
///
/// Every vertex of a bound body gets that bone at full weight. Bodies with no mapping fall back
/// to the nearest bone, so a partial mapping still produces a complete rig rather than a mesh
/// with holes in its weighting.
///
/// Follow it with SmoothWeights to soften the seams between bodies; on its own this produces
/// rigid parts, which is the correct result for machinery and the wrong one for a limb.
/// </summary>
public static SkinWeights BindBodies(
PolyMesh mesh,
IReadOnlyList<BodyRange> ranges,
IReadOnlyDictionary<string, string> bodyIdToBoneName,
Skeleton skeleton )
{
RequireBones( skeleton );
if ( ranges is null ) throw new ArgumentNullException( nameof( ranges ) );
if ( bodyIdToBoneName is null ) throw new ArgumentNullException( nameof( bodyIdToBoneName ) );
var fallback = BindRigid( mesh, skeleton );
var weights = new SkinWeights( mesh.VertexCount );
for ( var i = 0; i < mesh.VertexCount; i++ )
weights[i] = fallback[i];
foreach ( var range in ranges )
{
if ( !bodyIdToBoneName.TryGetValue( range.BodyId, out var boneName ) )
continue;
var bone = skeleton.IndexOf( boneName );
if ( bone < 0 )
throw new InvalidOperationException(
$"Body '{range.BodyName}' is bound to bone '{boneName}', which is not in the skeleton" );
var bound = new[] { new BoneWeight( bone, 1f ) };
for ( var i = range.Start; i < range.Start + range.Count && i < weights.Count; i++ )
weights[i] = bound;
}
return weights;
}
/// <summary>
/// Average each vertex's weights with its mesh neighbours, a few times.
///
/// This is what turns a rigid body-bound rig into one that bends: it diffuses weight ACROSS THE
/// SURFACE rather than through space, so it respects the mesh the way plain distance weighting
/// does not. It is a poor man's heat diffusion — same idea, no solver, run to a fixed iteration
/// count instead of to convergence.
///
/// `strength` is how far each pass moves toward the neighbour average; 1 is a full replace.
/// Each pass is an affine combination, so the partition of unity survives any number of them.
/// </summary>
public static SkinWeights SmoothWeights( PolyMesh mesh, SkinWeights weights, int iterations = 2, float strength = 0.5f )
{
if ( iterations <= 0 )
return weights;
strength = Math.Clamp( strength, 0f, 1f );
var neighbours = mesh.BuildVertexEdges();
var current = weights;
for ( var pass = 0; pass < iterations; pass++ )
{
var next = new SkinWeights( mesh.VertexCount );
for ( var v = 0; v < mesh.VertexCount; v++ )
{
var edges = neighbours[v];
if ( edges.Count == 0 )
{
next[v] = current[v];
continue;
}
var terms = new List<(BoneWeight[], float)>( edges.Count + 1 )
{
(current[v], 1f - strength)
};
var share = strength / edges.Count;
foreach ( var e in edges )
terms.Add( (current[e.A == v ? e.B : e.A], share) );
next[v] = SkinWeights.Blend( terms );
}
current = next;
}
return current;
}
// --- helpers -----------------------------------------------------------------------------
static void RequireBones( Skeleton skeleton )
{
if ( skeleton is null )
throw new ArgumentNullException( nameof( skeleton ) );
if ( skeleton.Count == 0 )
throw new InvalidOperationException( "Cannot bind to a skeleton with no bones" );
}
static (Vec3 Head, Vec3 Tail)[] Segments( Skeleton skeleton )
{
var segments = new (Vec3, Vec3)[skeleton.Count];
for ( var i = 0; i < skeleton.Count; i++ )
segments[i] = (skeleton.HeadWorld( i ), skeleton.TailWorld( i ));
return segments;
}
/// <summary>Distance from a point to a line segment — not to the infinite line, which would
/// let a short bone influence vertices far off its ends.</summary>
public static float DistanceToSegment( Vec3 p, Vec3 a, Vec3 b )
{
var ab = b - a;
var lengthSquared = ab.LengthSquared;
if ( lengthSquared < 1e-12f )
return (p - a).Length;
var t = Math.Clamp( Vec3.Dot( p - a, ab ) / lengthSquared, 0f, 1f );
return (p - (a + ab * t)).Length;
}
}