Editor-side code for garment fitting and trimming in the Effigy tool. Defines body regions and recipes, builds a garment mesh from body meshes (cutting faces by recipe spans), applies offsets, collision push-out, drape simulation, shaping (inflate, cinch, wrinkles), generates UVs, builds trims, validates garments and emits clothing slot mappings.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Effigy;
/// <summary>
/// Which part of a body a face belongs to, read from the rig rather than from the shape.
///
/// A GARMENT IS A RECIPE, NOT A MESH. "Torso and the top of the upper arms" means the same thing on
/// the Citizen, on Camhead and on Gearhead, and the rig is the one thing all three have in common.
/// The shape comes from the body itself - see <see cref="GarmentFit"/>.
/// </summary>
public enum BodyRegion
{
None,
Head,
Neck,
Torso,
Hips,
UpperArm,
LowerArm,
Hand,
UpperLeg,
LowerLeg,
Foot,
}
/// <summary>A garment's coverage: which regions, and how far along each region's bone.</summary>
public sealed class GarmentRecipe
{
public string Name;
/// <summary>
/// Region, the span of the bone it covers (0 at the bone's head, 1 at its tail), and which side
/// of the body it is on: 0 both, +1 left, -1 right.
///
/// THE SIDE IS WHAT MAKES REAL CLOTHES POSSIBLE. Everything a span system builds is symmetric by
/// construction, and a great deal of clothing is not - one sleeve, a strap over one shoulder, a
/// shirt hanging off one side. Tagging the span rather than the recipe means asymmetry composes:
/// a garment can be one-sleeved and off-shoulder at once, and each span still means what it
/// meant before for anything that does not use it.
/// </summary>
public List<(BodyRegion Region, float From, float To, int Side)> Spans = new();
public static readonly string[] Names =
{
"T-shirt", "Long sleeve", "Tank top", "Trousers", "Shorts", "Beanie", "Gloves", "Socks", "Bodysuit",
};
/// <summary>Which sleeves or legs a recipe keeps. Sides are the body's own - left is +y, the
/// way Source measures it - so "Left only" is the wearer's left, not the viewer's.</summary>
public static readonly string[] SideNames = { "Both", "Left only", "Right only" };
/// <summary>SideNames index to the span tag: both, left (+y), right (-y).</summary>
public static int SideOf( int index ) => index switch { 1 => 1, 2 => -1, _ => 0 };
/// <summary>
/// The recipe with its two lengths applied.
///
/// <paramref name="length"/> is the hem: how far a top reaches down over the hips, how far a leg
/// reaches down the leg, how deep a hat sits. <paramref name="sleeve"/> is how far down the arm a
/// sleeve goes, 0 bare to 1 at the wrist. Both are fractions so they mean the same thing on a
/// short body and a tall one.
/// </summary>
/// <param name="neckline">How far down the torso the garment STARTS, 0 at the neck. This is the
/// top edge, the counterpart of <paramref name="length"/>: raise it and the collar becomes a
/// scoop, then bares the shoulders, then becomes a tube top. The sleeve starts at the same drop,
/// because a sleeve still attached to a shoulder the body of the shirt has left behind is a
/// floating ring, not a garment.</param>
/// <param name="sleeveSide">Which sleeve to keep: 0 both, +1 left, -1 right.</param>
public static GarmentRecipe Build( int index, float length, float sleeve, float neckline = 0f,
int sleeveSide = 0 )
{
length = Math.Clamp( length, 0f, 1f );
sleeve = Math.Clamp( sleeve, 0f, 1f );
// Capped well short of 1: a neckline at the hem is a garment with no garment left, and the
// fit would refuse with "covers nothing" rather than explain itself.
neckline = Math.Clamp( neckline, 0f, 0.8f );
var r = new GarmentRecipe { Name = Names[Math.Clamp( index, 0, Names.Length - 1 )] };
void Arms( float s )
{
if ( s <= 0f )
return;
// The shoulder drop, shared with the torso. Halved because the upper arm's span runs
// shoulder to elbow and the torso's runs neck to waist - the same fraction would drop
// the sleeve twice as far down the arm as the neckline dropped down the chest.
var from = Math.Min( 0.6f, neckline * 0.5f );
r.Spans.Add( (BodyRegion.UpperArm, from, Math.Max( from, Math.Min( 1f, s * 2f ) ), sleeveSide) );
if ( s > 0.5f )
r.Spans.Add( (BodyRegion.LowerArm, 0f, (s - 0.5f) * 2f, sleeveSide) );
}
void Legs( float s )
{
r.Spans.Add( (BodyRegion.UpperLeg, 0f, Math.Min( 1f, Math.Max( 0.05f, s ) * 2f ), 0) );
if ( s > 0.5f )
r.Spans.Add( (BodyRegion.LowerLeg, 0f, (s - 0.5f) * 2f, 0) );
}
switch ( r.Name )
{
case "T-shirt":
case "Long sleeve":
case "Tank top":
r.Spans.Add( (BodyRegion.Torso, neckline, 1f, 0) );
r.Spans.Add( (BodyRegion.Hips, 0f, length, 0) );
Arms( r.Name == "Tank top" ? 0f : sleeve );
break;
case "Trousers":
case "Shorts":
r.Spans.Add( (BodyRegion.Hips, 0f, 1f, 0) );
Legs( length );
break;
case "Beanie":
r.Spans.Add( (BodyRegion.Head, 1f - Math.Max( 0.1f, length ), 1.01f, 0) );
break;
case "Gloves":
r.Spans.Add( (BodyRegion.Hand, 0f, 1.01f, sleeveSide) );
r.Spans.Add( (BodyRegion.LowerArm, 1f - length * 0.5f, 1.01f, sleeveSide) );
break;
case "Socks":
r.Spans.Add( (BodyRegion.Foot, 0f, 1.01f, 0) );
r.Spans.Add( (BodyRegion.LowerLeg, 1f - length * 0.6f, 1.01f, 0) );
break;
case "Bodysuit":
r.Spans.Add( (BodyRegion.Torso, neckline, 1f, 0) );
r.Spans.Add( (BodyRegion.Hips, 0f, 1f, 0) );
Arms( sleeve );
Legs( length );
break;
}
return r;
}
/// <summary>The defaults a recipe opens with: (length, sleeve).</summary>
public static (float Length, float Sleeve) Defaults( int index ) => Names[Math.Clamp( index, 0, Names.Length - 1 )] switch
{
"T-shirt" => (0.6f, 0.3f),
"Long sleeve" => (0.7f, 1f),
"Tank top" => (0.6f, 0f),
"Trousers" => (1f, 0f),
"Shorts" => (0.4f, 0f),
"Beanie" => (0.55f, 0f),
"Gloves" => (0.3f, 0f),
"Socks" => (0.4f, 0f),
_ => (1f, 1f),
};
/// <param name="side">Which side of the body the face is on: +1 left, -1 right. A span tagged 0
/// covers both, which is what every symmetric garment is made of.</param>
public bool Covers( BodyRegion region, float t, int side = 0 ) =>
Spans.Any( s => s.Region == region && t >= s.From - 1e-4f && t <= s.To + 1e-4f
&& (s.Side == 0 || s.Side == side) );
}
/// <summary>
/// Classify a skeleton's bones into regions by name. Covers Citizen's names (<c>arm_upper_R</c>,
/// <c>spine_2</c>), Unreal/Mixamo style (<c>upperarm_r</c>, <c>thigh_l</c>, <c>mixamorig:LeftForeArm</c>)
/// and plain English ones people type into the rig panel.
/// </summary>
public static class BodyRegions
{
public static BodyRegion Classify( string boneName )
{
var n = (boneName ?? "").ToLowerInvariant();
if ( n.Contains( "twist" ) )
n = n.Replace( "twist", "" );
bool Any( params string[] keys ) => keys.Any( n.Contains );
if ( Any( "finger", "thumb", "index", "middle", "ring_", "pinky", "hand" ) ) return BodyRegion.Hand;
if ( Any( "toe", "foot", "ball_" ) ) return BodyRegion.Foot;
if ( Any( "head", "eye", "jaw", "face" ) ) return BodyRegion.Head;
if ( Any( "neck" ) ) return BodyRegion.Neck;
if ( Any( "arm_lower", "forearm", "lowerarm", "lower_arm", "elbow" ) ) return BodyRegion.LowerArm;
if ( Any( "arm_upper", "upperarm", "upper_arm" ) ) return BodyRegion.UpperArm;
if ( Any( "leg_lower", "calf", "shin", "lowerleg", "lower_leg", "knee" ) ) return BodyRegion.LowerLeg;
if ( Any( "leg_upper", "thigh", "upperleg", "upper_leg" ) ) return BodyRegion.UpperLeg;
if ( Any( "pelvis", "hip" ) ) return BodyRegion.Hips;
if ( Any( "spine", "chest", "clavicle", "torso", "abdomen", "belly", "collar", "shoulder" ) ) return BodyRegion.Torso;
if ( Any( "arm" ) ) return BodyRegion.UpperArm;
if ( Any( "leg" ) ) return BodyRegion.UpperLeg;
return BodyRegion.None;
}
/// <summary>
/// The region nearest a point, and how far along that region's bone it sits.
///
/// "How far along" is measured on the nearest bone of the region, and a region with a chain of
/// bones (a spine, a twist chain) is measured along the whole chain, so 0.5 is halfway down the
/// upper arm whether that arm is one bone or three.
/// </summary>
public sealed class Map
{
readonly List<(BodyRegion Region, Vec3 A, Vec3 B)> _segments = new();
readonly List<int> _segmentBones = new();
public int Count => _segments.Count;
/// <summary>Every region bone as a segment, for fitting capsules to.</summary>
public IReadOnlyList<(BodyRegion Region, Vec3 A, Vec3 B)> Segments => _segments;
/// <summary>The skeleton bone each of <see cref="Segments"/> came from.</summary>
public IReadOnlyList<int> SegmentBones => _segmentBones;
public Map( Skeleton skeleton )
{
for ( var i = 0; i < skeleton.Count; i++ )
{
var region = Classify( skeleton.Bones[i].Name );
if ( region == BodyRegion.None )
continue;
var a = skeleton.HeadWorld( i );
var b = skeleton.TailWorld( i );
if ( (b - a).LengthSquared < 1e-8f )
continue;
_segments.Add( (region, a, b) );
_segmentBones.Add( i );
}
}
/// <summary>Nearest region to <paramref name="p"/>, and t along it (0 at the joint nearest
/// the body's centre, 1 at the far end).</summary>
public (BodyRegion Region, float T) Locate( Vec3 p ) => Locate( p, BodyRegion.None );
/// <summary>As <see cref="Locate(Vec3)"/>, but only among <paramref name="only"/>'s bones -
/// for a surface that already knows which region it belongs to, such as an envelope hull.
/// None means any region.</summary>
public (BodyRegion Region, float T) Locate( Vec3 p, BodyRegion only )
{
var best = float.MaxValue;
var region = BodyRegion.None;
var chainT = 0f;
foreach ( var (r, a, b ) in _segments )
{
if ( only != BodyRegion.None && r != only )
continue;
var ab = b - a;
var t = Math.Clamp( Vec3.Dot( p - a, ab ) / ab.LengthSquared, 0f, 1f );
var d = (a + ab * t - p).LengthSquared;
if ( d < best )
{
best = d;
region = r;
chainT = t;
}
}
if ( region == BodyRegion.None )
return (region, 0f);
// Chains: measure t over the whole region on this side, start to end, so a limb reads
// 0..1 shoulder to wrist whether it is one bone or a bone with twist bones laid over it
// (Citizen's are - summing their lengths would count the arm twice).
if ( Chain( region, Side( p ), out var from, out var to ) )
{
var axis = to - from;
chainT = Math.Clamp( Vec3.Dot( p - from, axis ) / axis.LengthSquared, 0f, 1f );
}
// The head is measured bottom-up (a beanie covers the TOP), the torso and hips top-down
// (a hem reads down from the collar).
if ( region == BodyRegion.Head )
chainT = 1f - chainT;
return (region, chainT);
}
/// <summary>
/// The line a region runs along, on one side of the body: from the end nearest the body's
/// centre (the top, for anything on the spine or a leg) to the far end. This is the axis t
/// is measured on and the axis a limb's UVs wrap around, so both agree about where a
/// sleeve starts. False for a region the skeleton does not have.
/// </summary>
public bool Chain( BodyRegion region, int side, out Vec3 from, out Vec3 to )
{
from = to = Vec3.Zero;
var ends = new List<Vec3>();
foreach ( var s in _segments )
{
if ( s.Region != region || (!IsCentral( region ) && Side( (s.A + s.B) * 0.5f ) != side) )
continue;
ends.Add( s.A );
ends.Add( s.B );
}
if ( ends.Count < 2 )
return false;
from = IsCentral( region ) || region is BodyRegion.UpperLeg or BodyRegion.LowerLeg or BodyRegion.Foot
? ends.OrderByDescending( e => e.z ).First()
: ends.OrderBy( e => MathF.Abs( e.y ) ).First();
var start = from;
to = ends.OrderByDescending( e => (e - start).LengthSquared ).First();
return (to - from).LengthSquared > 1e-8f;
}
public static bool IsCentral( BodyRegion r ) => r is BodyRegion.Head or BodyRegion.Neck or BodyRegion.Torso or BodyRegion.Hips;
static int Side( Vec3 p ) => p.y > 0 ? 1 : -1;
}
}
/// <summary>
/// Make a garment that fits a particular body.
///
/// THE BODY DOES MOST OF THE WORK. The rig only says which faces a recipe covers; the garment is then
/// lifted off the body's own surface, so it starts with that body's proportions and topology - which
/// is also the topology the s&box quality bar asks clothing to follow. After that:
///
/// 1. every vertex is pushed out to the fit offset along its normal,
/// 2. collision: any vertex closer than the clearance to ANY body - the chest pressing into an arm, a
/// collar into a cog - is pushed back out along that surface's normal, and smoothed, a few times,
/// 3. the openings flare out if asked,
/// 4. the sheet is given thickness with <see cref="MeshSolidify"/>.
///
/// It does not drape. Folds come from the flare and from sculpting until editor cloth arrives.
/// </summary>
public static class GarmentFit
{
public sealed class Options
{
/// <summary>Round colliders for the drape instead of the bodies' triangles. See Capsule.
/// Null drapes against the meshes, as before.</summary>
public List<Capsule> Capsules;
/// <summary>How far the drape may move the cloth from where the fit put it. 0 takes the
/// default: a couple of inches, room to settle and no more.</summary>
public float Tether;
/// <summary>How far off the body the garment sits, in model units (inches on the Citizen).</summary>
public float Offset = 0.4f;
/// <summary>The closest any part of the garment may come to any body, after collision.</summary>
public float Clearance = 0.15f;
/// <summary>How far the openings (hems, cuffs) flare out beyond the offset.</summary>
public float Flare;
/// <summary>How far up from an opening the flare reaches.</summary>
public float FlareReach = 4f;
/// <summary>Cloth thickness; 0 leaves a single-sided sheet.</summary>
public float Thickness = 0.12f;
/// <summary>
/// Inflate the garment along its own normals, in inches. Puffed sleeves, a padded jacket,
/// a quilted vest.
///
/// Marvelous Designer calls this Pressure and Simply Cloth calls it the same; it is on both
/// tools' front page because it is the cheapest way to turn a fitted shape into a different
/// GARMENT rather than the same garment at a different size. Looseness moves the whole
/// surface away from the body uniformly, which reads as a bigger shirt. This pushes along
/// the local normal, which reads as stuffing.
/// </summary>
public float Puff;
/// <summary>
/// Pull the openings in towards their own centres, 0 none to 1 closed. A waistband, elastic
/// cuffs, a gathered ankle on a jogger.
///
/// The hems are the only part of a garment that has nothing holding it in, so they are the
/// part that reads as loose whatever the rest is doing - which is why "tighten the waistband"
/// is a thing people ask for and "make the shirt smaller" is not the same request.
/// </summary>
public float Cinch;
/// <summary>How far up from an opening the cinch reaches, in inches. A waistband is an inch;
/// a gathered ankle is three.</summary>
public float CinchReach = 1.5f;
/// <summary>
/// Creases along the surface, 0 smooth to 1 rumpled.
///
/// NOT A SUBSTITUTE FOR THE DRAPE. The solver makes the folds that gravity and collision
/// actually produce, and those are the ones that sit in the right places. This is the fine
/// noise on top - the crumple of cloth that has been worn - which a solver would need a much
/// finer mesh and much longer to find, for something nobody looks at closely enough to tell
/// apart from noise.
/// </summary>
public float Wrinkles;
/// <summary>Size of a wrinkle, in inches. Small is linen, large is a heavy coat.</summary>
public float WrinkleScale = 2.2f;
/// <summary>Collision passes. Each pass pushes out and relaxes once.</summary>
public int Passes = 4;
public int MaterialSlot;
/// <summary>Let the garment settle under gravity against the bodies before it is thickened.</summary>
public bool Drape = true;
/// <summary>
/// What the cloth is made of, as a set of solver settings.
///
/// THE FIRST THING EITHER OF THE BIG TOOLS ASKS. Marvelous Designer opens on a fabric
/// library and Simply Cloth ships presets, because the difference between cotton and leather
/// is most of the difference between two garments of the same shape - it decides whether the
/// folds are many and soft or few and standing. Stiffness alone could only say how much a
/// garment resists being stretched, which is the one property people do not think in.
///
/// A STARTING POINT, NOT A MEASUREMENT. These are artistic presets tuned to look right at
/// this scale, not the physical constants of real textiles; Stiffness still overrides the
/// stretch on top of whichever is chosen.
/// </summary>
public Fabric Fabric = Fabric.Cotton;
/// <summary>Simulation steps, at 1/60 s each. More settles further and costs more.</summary>
public int DrapeSteps = 40;
/// <summary>0 = stretchy jersey, 1 = stiff canvas. How hard the edges hold their fitted length.</summary>
public float Stiffness = 0.8f;
/// <summary>Pin the top fraction of the garment (by height) where it is, so trousers keep their
/// waistband. 0 pins nothing - a top rests on the shoulders by collision alone.</summary>
public float PinTop;
/// <summary>
/// Stop the garment passing through itself where it folds. OFF by default, and deliberately:
/// it is several times the cost of the whole rest of the drape, and this runs on every
/// rebuild while a slider moves. The fit pass has already pushed the garment out of the body,
/// so what this buys is folds resolving against each other - worth asking for on a finished
/// garment, not worth paying for on every drag.
/// </summary>
public bool SelfCollision;
}
public sealed class Result
{
public PolyMesh Mesh;
public int SourceFaces;
public int DroppedInside;
public bool Solidified;
public string SolidifyProblem;
}
/// <summary>
/// Build <paramref name="recipe"/> on <paramref name="bodies"/>, located with <paramref name="map"/>.
/// Returns null when the recipe covers no face at all.
/// </summary>
public static Result Fit( IReadOnlyList<PolyMesh> wearers, BodyRegions.Map map, GarmentRecipe recipe, Options o,
IReadOnlyList<PolyMesh> otherGarments = null, IReadOnlyList<BodyRegion> wearerRegions = null )
{
// Everything collides; only the wearers are cut from.
var bodies = wearers.Concat( otherGarments ?? Array.Empty<PolyMesh>() ).ToList();
var trees = bodies.Select( MeshBVH.Build ).ToList();
var closed = bodies.Select( b => MeshValidator.Validate( b ).IsClosed ).ToList();
var result = new Result { Mesh = new PolyMesh() };
var garment = result.Mesh;
var remap = new Dictionary<(int Body, int Vertex), int>();
var cutRemap = new Dictionary<(int Body, int A, int B, BodyRegion Region, int End), int>();
for ( var bi = 0; bi < wearers.Count; bi++ )
{
var body = bodies[bi];
// A wearer that already knows its region - an envelope hull - keeps it, and is only
// measured along it. Left to the nearest bone, the front of Camhead's torso hull was
// claimed by the arms hanging beside it and cut out of the shirt.
var only = wearerRegions is not null && bi < wearerRegions.Count ? wearerRegions[bi] : BodyRegion.None;
int Vertex( int vi )
{
var key = (bi, vi);
if ( !remap.TryGetValue( key, out var index ) )
{
index = garment.AddVertex( body.Positions[vi] );
remap[key] = index;
}
return index;
}
for ( var fi = 0; fi < body.FaceCount; fi++ )
{
var face = body.Faces[fi];
if ( face.Count < 3 )
continue;
var centroid = body.FaceCentroid( face );
var (region, t) = map.Locate( centroid, only );
// The side is the face's own position, not the bone's: +y is the body's left, the
// way Source measures it. Taken from the face rather than from the bone name so an
// imported skeleton with no _L / _R convention still makes a one-sleeved shirt.
var side = centroid.y > 0 ? 1 : -1;
var spans = recipe.Spans.Where( s => s.Region == region && (s.Side == 0 || s.Side == side) ).ToList();
if ( spans.Count == 0 )
continue;
static bool Within( (BodyRegion Region, float From, float To, int Side) s, float t ) =>
t >= s.From - 1e-4f && t <= s.To + 1e-4f;
// THE FACE IS CUT AT THE HEM, not kept or dropped whole. Whole faces gave every
// opening a sawtooth edge one face deep - the jagged cuffs and collars that said
// "generated" from across the room - and no amount of relaxing put a straight line
// back. The span's two ends are two planes across the bone (t is linear along it),
// and the polygon is clipped to each (Sutherland-Hodgman), so the edge is a clean
// line round the limb. An end at 0 or 1 is open - a sleeve runs into the shoulder,
// a glove past the fingertips - and is not cut. A face is in if any corner of it
// is, and it is the span round the centre, or failing that round a corner, that
// does the cutting.
var nodes = new List<CutNode>( face.Count + 2 );
var span = spans[0];
var chained = map.Chain( region, side, out var from, out var to );
if ( chained )
{
var axis = to - from;
var lengthSq = axis.LengthSquared;
for ( var c = 0; c < face.Count; c++ )
{
var vi = face.Indices[c];
var p = body.Positions[vi];
nodes.Add( new CutNode( vi, vi, -1, p, Vec3.Dot( p - from, axis ) / lengthSq ) );
}
var covering = spans.FirstOrDefault( s => Within( s, t ) );
if ( covering.Region != region )
covering = spans.FirstOrDefault( s => nodes.Any( n => Within( s, n.T ) ) );
if ( covering.Region != region )
continue;
span = covering;
}
else if ( !spans.Any( s => Within( s, t ) ) )
continue;
// A face buried inside another part - an arm cylinder's end inside the torso block,
// the way CAD characters are built - would be pushed out onto the surface as a crumpled
// flap. It is not skin; skip it.
if ( InsideOther( bodies, trees, closed, bi, centroid ) )
{
result.DroppedInside++;
continue;
}
if ( chained )
{
if ( span.From > 0f )
nodes = ClipSpan( nodes, span.From, keepAbove: true, end: 0 );
if ( span.To < 1f && nodes.Count >= 3 )
nodes = ClipSpan( nodes, span.To, keepAbove: false, end: 1 );
if ( nodes.Count < 3 )
continue;
}
else
{
for ( var c = 0; c < face.Count; c++ )
nodes.Add( new CutNode( face.Indices[c], face.Indices[c], -1, body.Positions[face.Indices[c]], 0f ) );
}
var indices = new int[nodes.Count];
for ( var c = 0; c < nodes.Count; c++ )
{
var node = nodes[c];
// An original corner; or a cut point on an original edge, which the face on the
// other side of that edge finds again by the same key, so the cut is watertight.
if ( node.End < 0 )
indices[c] = Vertex( node.A );
else if ( node.A >= 0 )
{
var (lo, hi) = node.A < node.B ? (node.A, node.B) : (node.B, node.A);
var key = (bi, lo, hi, region, node.End);
if ( !cutRemap.TryGetValue( key, out var index ) )
{
index = garment.AddVertex( node.P );
cutRemap[key] = index;
}
indices[c] = index;
}
else
indices[c] = garment.AddVertex( node.P );
}
garment.AddFace( indices, null, o.MaterialSlot );
result.SourceFaces++;
}
}
if ( garment.FaceCount == 0 )
return null;
var start = garment.Positions.ToArray();
var normals = garment.ComputeVertexNormals();
var boundary = BoundaryVertices( garment );
var flare = FlareWeights( garment, boundary, o.FlareReach );
// 1. Out to the fit offset, flaring toward the openings.
for ( var i = 0; i < garment.VertexCount; i++ )
garment.Positions[i] = start[i] + normals[i] * (o.Offset + o.Flare * flare[i]);
// 2. Collision against every body, with a relax between passes so a push does not leave a dent.
var neighbours = Neighbours( garment );
var reach = o.Offset + o.Flare + o.Clearance + 2f;
for ( var pass = 0; pass < Math.Max( 1, o.Passes ); pass++ )
{
PushOut( garment, bodies, trees, o.Clearance, reach );
Relax( garment, neighbours, boundary, 0.35f );
}
PushOut( garment, bodies, trees, o.Clearance, reach );
if ( o.Drape && o.DrapeSteps > 0 )
Drape( garment, bodies, o );
// SHAPING COMES AFTER THE DRAPE AND BEFORE THE THICKNESS, and the order is the whole
// correctness argument. After the drape, because these are deliberate style decisions and
// the solver would flatten a puff straight back out as if it were an error. Before the
// thickness, because Solidify offsets the surface it is given - shaping a solid would move
// its inner and outer shells apart by different amounts and split the garment open.
//
// Then pushed out again: a puff or a wrinkle can drive the cloth back into the body, and a
// garment that clips is worse than one that is not quite as puffy as asked.
if ( o.Puff > 0f )
Inflate( garment, o.Puff );
if ( o.Cinch > 0f )
Cinch( garment, o.Cinch, o.CinchReach );
if ( o.Wrinkles > 0f )
Wrinkle( garment, o.Wrinkles, o.WrinkleScale );
if ( o.Puff > 0f || o.Wrinkles > 0f )
PushOut( garment, bodies, trees, o.Clearance, reach );
LimbUVs( garment, map );
if ( o.Thickness > 0f )
{
try
{
garment = MeshSolidify.Solidify( garment, o.Thickness );
result.Solidified = true;
}
catch ( Exception e )
{
result.SolidifyProblem = e.Message;
}
}
result.Mesh = garment;
return result;
}
/// <summary>A corner of a face being cut: an original vertex (A == B, End -1), or a point on
/// the original edge A-B where the span's end (0 the start, 1 the finish) crosses it. A is -1
/// for a point on an edge the cut itself made, which no other face shares.</summary>
readonly record struct CutNode( int A, int B, int End, Vec3 P, float T );
/// <summary>One Sutherland-Hodgman pass: keep the side of t = limit the span is on.</summary>
static List<CutNode> ClipSpan( List<CutNode> nodes, float limit, bool keepAbove, int end )
{
var kept = new List<CutNode>( nodes.Count + 2 );
for ( var i = 0; i < nodes.Count; i++ )
{
var cur = nodes[i];
var nxt = nodes[(i + 1) % nodes.Count];
var inCur = keepAbove ? cur.T >= limit : cur.T <= limit;
var inNxt = keepAbove ? nxt.T >= limit : nxt.T <= limit;
if ( inCur )
kept.Add( cur );
if ( inCur == inNxt )
continue;
var s = Math.Clamp( (limit - cur.T) / (nxt.T - cur.T), 0f, 1f );
var (a, b) = EdgeUnder( cur, nxt );
kept.Add( new CutNode( a, b, end, cur.P + (nxt.P - cur.P) * s, limit ) );
}
return kept;
}
/// <summary>The original edge a segment between two nodes lies on, or (-1, -1) when it is a
/// line the first cut drew across the face and nothing else can share.</summary>
static (int, int) EdgeUnder( CutNode x, CutNode y )
{
static bool OnEdge( CutNode cut, int vertex ) => cut.A == vertex || cut.B == vertex;
if ( x.End < 0 && y.End < 0 )
return (x.A, y.A);
if ( x.End < 0 && y.A >= 0 && OnEdge( y, x.A ) )
return (y.A, y.B);
if ( y.End < 0 && x.A >= 0 && OnEdge( x, y.A ) )
return (x.A, x.B);
if ( x.End >= 0 && y.End >= 0 && x.A >= 0 && x.A == y.A && x.B == y.B )
return (x.A, x.B);
return (-1, -1);
}
/// <summary>
/// Push every vertex out along its own normal. Pressure, in Marvelous Designer's terms.
///
/// The normals are computed ONCE, from the shape as it arrives, rather than per vertex as it
/// moves. Recomputing as you go makes a bulge feed itself - the more a region inflates the more
/// outward its neighbours' normals become - and a padded jacket turns into a balloon.
/// </summary>
static void Inflate( PolyMesh garment, float amount )
{
var normals = garment.ComputeVertexNormals();
for ( var i = 0; i < garment.VertexCount; i++ )
garment.Positions[i] += normals[i] * amount;
}
/// <summary>
/// Pull the openings in towards their own centres: waistbands, elastic cuffs, gathered ankles.
///
/// The pull falls off with distance from the edge, so the garment is gathered at the hem and
/// untouched an inch above it - which is what elastic in a casing actually does, and what makes
/// this read as a waistband rather than as a garment that tapers.
/// </summary>
static void Cinch( PolyMesh garment, float amount, float reach )
{
var loops = GarmentTrim.OrderedBoundaryLoops( garment );
if ( loops.Count == 0 || reach <= 1e-4f )
return;
amount = Math.Clamp( amount, 0f, 1f );
// Every vertex moves towards the centre of the NEAREST opening, weighted by how close it is
// to that opening's edge. Nearest rather than "the opening it belongs to": a garment has no
// notion of which panel a vertex is on, and distance answers the question correctly anyway.
var centres = new List<Vec3>();
var rings = new List<List<Vec3>>();
foreach ( var loop in loops )
{
var points = loop.Select( v => garment.Positions[v] ).ToList();
var centre = Vec3.Zero;
foreach ( var p in points )
centre += p;
centres.Add( centre / points.Count );
rings.Add( points );
}
for ( var i = 0; i < garment.VertexCount; i++ )
{
var p = garment.Positions[i];
var best = float.MaxValue;
var which = -1;
for ( var li = 0; li < rings.Count; li++ )
{
foreach ( var q in rings[li] )
{
var d = (q - p).LengthSquared;
if ( d >= best )
continue;
best = d;
which = li;
}
}
if ( which < 0 )
continue;
var distance = MathF.Sqrt( best );
if ( distance >= reach )
continue;
// Smoothstep rather than linear, so the top of the band blends into the garment instead
// of leaving a crease exactly `reach` inches up.
var t = 1f - distance / reach;
var falloff = t * t * (3f - 2f * t);
var centre = centres[which];
// Along the opening's own plane only: pulling towards the centre POINT would drag the
// hem up the body as well as in, and shorten the garment as a side effect of tightening
// it. Only the part of the offset perpendicular to the ring's axis is wanted.
var toCentre = centre - p;
var axis = RingAxis( rings[which], centre );
toCentre -= axis * Vec3.Dot( toCentre, axis );
garment.Positions[i] = p + toCentre * (amount * falloff);
}
}
/// <summary>The direction an opening faces: the normal of the plane its vertices best lie in.
/// Found by summing the fan of cross products around the ring, which is the ring's own area
/// vector and needs no fitting.</summary>
static Vec3 RingAxis( List<Vec3> ring, Vec3 centre )
{
var area = Vec3.Zero;
for ( var i = 0; i < ring.Count; i++ )
area += Vec3.Cross( ring[i] - centre, ring[(i + 1) % ring.Count] - centre );
return area.LengthSquared < 1e-8f ? new Vec3( 0, 0, 1 ) : area.Normal;
}
/// <summary>
/// Fine creases along the surface. See Options.Wrinkles for what this is and is not.
///
/// Three octaves of a cheap value noise over the vertex's own position, displaced along the
/// normal. Position-based rather than UV-based on purpose: UVs are laid out per panel, so a
/// UV-space wrinkle would stop dead at every seam, and cloth does not.
/// </summary>
static void Wrinkle( PolyMesh garment, float amount, float scale )
{
if ( scale <= 1e-3f )
return;
var normals = garment.ComputeVertexNormals();
for ( var i = 0; i < garment.VertexCount; i++ )
{
var p = garment.Positions[i] / scale;
var n = Noise( p ) * 0.6f
+ Noise( p * 2.3f ) * 0.3f
+ Noise( p * 4.7f ) * 0.1f;
garment.Positions[i] += normals[i] * (n * amount * scale * 0.09f);
}
}
/// <summary>A cheap deterministic value noise in -1..1. Deterministic matters more than
/// quality: the same garment must rebuild to the same wrinkles, or every rebuild would be a
/// different model and undo would be a surprise.</summary>
static float Noise( Vec3 p )
{
var x = MathF.Sin( p.x * 12.9898f + p.y * 78.233f + p.z * 37.719f ) * 43758.5453f;
var y = MathF.Sin( p.x * 93.9898f + p.y * 67.345f + p.z * 24.123f ) * 28001.8384f;
return (x - MathF.Floor( x )) + (y - MathF.Floor( y )) - 1f;
}
static bool InsideOther( IReadOnlyList<PolyMesh> bodies, List<MeshBVH> trees, List<bool> closed, int self, Vec3 p )
{
for ( var bi = 0; bi < bodies.Count; bi++ )
{
if ( bi == self || !closed[bi] )
continue;
if ( trees[bi].NearestSurface( bodies[bi], p, 1e6f ) is not { } hit )
continue;
if ( hit.Distance > 1e-4f && Vec3.Dot( p - hit.Point, hit.Normal ) < 0 )
return true;
}
return false;
}
internal static void PushOut( PolyMesh garment, IReadOnlyList<PolyMesh> bodies, List<MeshBVH> trees, float clearance, float reach )
{
for ( var i = 0; i < garment.VertexCount; i++ )
{
var p = garment.Positions[i];
for ( var bi = 0; bi < bodies.Count; bi++ )
{
if ( trees[bi].NearestSurface( bodies[bi], p, reach ) is not { } hit )
continue;
var along = Vec3.Dot( p - hit.Point, hit.Normal );
if ( along < clearance )
p += hit.Normal * (clearance - along);
}
garment.Positions[i] = p;
}
}
static void Relax( PolyMesh mesh, List<int>[] neighbours, bool[] boundary, float amount )
{
var next = mesh.Positions.ToArray();
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var n = neighbours[i];
if ( n.Count == 0 )
continue;
var avg = Vec3.Zero;
foreach ( var j in n )
avg += mesh.Positions[j];
avg /= n.Count;
next[i] = Vec3.Lerp( mesh.Positions[i], avg, boundary[i] ? amount * 0.3f : amount );
}
for ( var i = 0; i < next.Length; i++ )
mesh.Positions[i] = next[i];
}
static List<int>[] Neighbours( PolyMesh mesh )
{
var sets = new HashSet<int>[mesh.VertexCount];
for ( var i = 0; i < sets.Length; i++ )
sets[i] = new HashSet<int>();
foreach ( var f in mesh.Faces )
{
for ( var c = 0; c < f.Count; c++ )
{
var a = f.Indices[c];
var b = f.Indices[(c + 1) % f.Count];
sets[a].Add( b );
sets[b].Add( a );
}
}
return sets.Select( s => s.ToList() ).ToArray();
}
/// <summary>
/// Let the fitted garment settle under gravity against the bodies, using the kernel's one cloth
/// solver. <see cref="Options.Stiffness"/> becomes XPBD stretch compliance — 1 is canvas that
/// holds its fitted length, 0 is jersey that gives — and <see cref="Options.PinTop"/> pins the
/// top slice of the mesh so a waistband stays where it was fitted.
/// </summary>
static void Drape( PolyMesh garment, IReadOnlyList<PolyMesh> bodies, Options o )
{
// The fabric first, then the garment's own settings over the top. Order matters: the preset
// is what the cloth IS and Stiffness is the adjustment somebody made to it, so the
// adjustment has to win - the other way round, moving the slider would do nothing on any
// fabric that sets its own stretch.
var sim = ClothSim.WithFabric( garment, PinnedTop( garment, o.PinTop ), o.Fabric );
sim.Thickness = MathF.Max( o.Clearance, 1e-3f );
sim.SelfCollision = o.SelfCollision;
// Stiffness runs 0..1 and compliance runs rigid..slack over four decades, so the map is
// exponential: 1 -> 1e-7 (canvas), 0.5 -> 1e-5, 0 -> 1e-3 (jersey).
var stretch = MathF.Pow( 10f, -7f + 4f * (1f - Math.Clamp( o.Stiffness, 0f, 1f )) );
// Stretch is scaled rather than replaced, so a stretch fabric stays stretchier than denim at
// the same slider position instead of every fabric collapsing onto one curve.
sim.StretchCompliance = MathF.Max( sim.StretchCompliance, 1e-7f ) / 1e-5f * stretch;
sim.BendCompliance = MathF.Max( sim.BendCompliance, sim.StretchCompliance * 10f );
// The garment was fitted to lie where it should; the drape only settles it. Two inches, or
// four clearances on a loose fit, is room for a hem to fall and nowhere near room to drop
// through a body with gaps in it. See ClothSim.Tether.
sim.Tether = o.Tether > 0f ? o.Tether : MathF.Max( 2f, o.Clearance * 4f );
if ( o.Capsules is { Count: > 0 } )
sim.Capsules.AddRange( o.Capsules );
else
sim.SetColliders( bodies );
sim.Run( o.DrapeSteps / 60f );
for ( var i = 0; i < garment.VertexCount; i++ )
garment.Positions[i] = sim.Positions[i];
}
/// <summary>Vertices in the top <paramref name="fraction"/> of the mesh's height. Empty when the
/// fraction is 0, which pins nothing and lets the garment rest on the body by collision alone.</summary>
static IEnumerable<int> PinnedTop( PolyMesh mesh, float fraction )
{
fraction = Math.Clamp( fraction, 0f, 1f );
if ( fraction <= 0f || mesh.VertexCount == 0 )
yield break;
var top = mesh.Positions.Max( p => p.z );
var bottom = mesh.Positions.Min( p => p.z );
var cut = top - (top - bottom) * fraction;
for ( var i = 0; i < mesh.VertexCount; i++ )
if ( mesh.Positions[i].z >= cut )
yield return i;
}
public static bool[] BoundaryVertices( PolyMesh mesh )
{
var boundary = new bool[mesh.VertexCount];
foreach ( var (edge, faces) in mesh.BuildEdgeFaces() )
{
if ( faces.Count == 1 )
{
boundary[edge.A] = true;
boundary[edge.B] = true;
}
}
return boundary;
}
/// <summary>1 at an opening, falling to 0 at <paramref name="reach"/> away from it.</summary>
public static float[] FlareWeights( PolyMesh mesh, bool[] boundary, float reach )
{
var weights = new float[mesh.VertexCount];
var edge = new List<Vec3>();
for ( var i = 0; i < boundary.Length; i++ )
{
if ( boundary[i] )
edge.Add( mesh.Positions[i] );
}
if ( edge.Count == 0 || reach <= 0f )
return weights;
for ( var i = 0; i < mesh.VertexCount; i++ )
{
var p = mesh.Positions[i];
var best = float.MaxValue;
foreach ( var e in edge )
best = Math.Min( best, (e - p).LengthSquared );
var d = MathF.Sqrt( best );
var w = Math.Clamp( 1f - d / reach, 0f, 1f );
weights[i] = w * w;
}
return weights;
}
/// <summary>
/// UVs wrapped round each limb on its own bone, a foot to a UV unit everywhere.
///
/// ONE CYLINDER ROUND THE WHOLE BODY WAS WRONG FOR THE SLEEVES. CylinderUVs wraps everything
/// about the body's vertical axis, which is right for the torso and stretches a sleeve into a
/// smear: the arm is a tube lying across that projection, so its texture ran the wrong way and
/// was squeezed to a sliver on the top and bottom of the arm. Trouser legs the same, less so.
/// So each face is projected round the bone chain its region runs along - the torso's spine,
/// each arm, each leg - with the angle scaled by that limb's own girth, so a check the size of
/// a thumbnail on the chest is the size of a thumbnail on the cuff. The seam between a region
/// and the next is a UV seam, as it is on every real garment.
/// </summary>
public static void LimbUVs( PolyMesh mesh, BodyRegions.Map map )
{
const float UnitsPerUv = 12f;
// Every face's chain, and every chain's girth from the faces on it.
var chains = new Dictionary<(BodyRegion, int), (Vec3 From, Vec3 Axis, Vec3 E1, Vec3 E2, float Radius, int Count)>();
var faceChain = new (BodyRegion, int)[mesh.FaceCount];
for ( var fi = 0; fi < mesh.FaceCount; fi++ )
{
var centroid = mesh.FaceCentroid( mesh.Faces[fi] );
var (region, _) = map.Locate( centroid );
var side = BodyRegions.Map.IsCentral( region ) ? 0 : centroid.y > 0 ? 1 : -1;
var key = (region, side);
faceChain[fi] = key;
if ( !chains.TryGetValue( key, out var chain ) )
{
Vec3 from, to;
if ( region == BodyRegion.None || !map.Chain( region, side == 0 ? 1 : side, out from, out to ) )
{
// Nothing to hang it on: the old projection about the vertical.
from = Vec3.Zero;
to = new Vec3( 0f, 0f, -1f );
}
var axis = (to - from).Normal;
var reference = MathF.Abs( axis.z ) < 0.9f ? new Vec3( 0f, 0f, 1f ) : new Vec3( 1f, 0f, 0f );
var e1 = Vec3.Cross( reference, axis ).Normal;
var e2 = Vec3.Cross( axis, e1 );
chain = (from, axis, e1, e2, 0f, 0);
}
var q = centroid - chain.From;
var radial = q - chain.Axis * Vec3.Dot( q, chain.Axis );
chain.Radius += radial.Length;
chain.Count++;
chains[key] = chain;
}
foreach ( var fi in Enumerable.Range( 0, mesh.FaceCount ) )
{
var face = mesh.Faces[fi];
var chain = chains[faceChain[fi]];
var radius = MathF.Max( 1f, chain.Radius / Math.Max( 1, chain.Count ) );
var span = MathF.PI * 2f * radius / UnitsPerUv;
var uvs = new Vec2[face.Count];
for ( var c = 0; c < face.Count; c++ )
{
var q = mesh.Positions[face.Indices[c]] - chain.From;
var angle = MathF.Atan2( Vec3.Dot( q, chain.E2 ), Vec3.Dot( q, chain.E1 ) );
uvs[c] = new Vec2( angle * radius / UnitsPerUv, Vec3.Dot( q, chain.Axis ) / UnitsPerUv );
}
// A face straddling the seam at +-180 degrees would stretch across the whole texture.
var max = uvs.Max( u => u.x );
for ( var c = 0; c < uvs.Length; c++ )
if ( max - uvs[c].x > span * 0.5f )
uvs[c] = new Vec2( uvs[c].x + span, uvs[c].y );
face.UVs = uvs;
}
}
/// <summary>
/// A wrap-around projection about the vertical axis, a foot to a UV unit. Fabric tiles and fur
/// noise both want something even; the body's own UVs are laid out for the body, and a CAD part
/// may have none. LimbUVs is what Fit uses now; this is kept for a mesh with no rig to go by.
/// </summary>
public static void CylinderUVs( PolyMesh mesh )
{
const float UnitsPerUv = 12f;
var centre = Vec3.Zero;
foreach ( var p in mesh.Positions )
centre += p;
centre /= Math.Max( 1, mesh.VertexCount );
foreach ( var face in mesh.Faces )
{
var uvs = new Vec2[face.Count];
for ( var c = 0; c < face.Count; c++ )
{
var p = mesh.Positions[face.Indices[c]];
var angle = MathF.Atan2( p.y - centre.y, p.x - centre.x );
uvs[c] = new Vec2( angle / (MathF.PI * 2f) * (MathF.PI * 2f * 8f) / UnitsPerUv, -p.z / UnitsPerUv );
}
// A face straddling the seam at +-180 degrees would stretch across the whole texture.
var span = MathF.PI * 2f * 8f / UnitsPerUv;
var max = uvs.Max( u => u.x );
for ( var c = 0; c < uvs.Length; c++ )
{
if ( max - uvs[c].x > span * 0.5f )
uvs[c] = new Vec2( uvs[c].x + span, uvs[c].y );
}
face.UVs = uvs;
}
}
}
/// <summary>
/// What is wrong with a garment before you export it, said in the terms of the job.
///
/// WHY THIS IS NOT MeshValidator. That answers "is this a mesh" — corners, indices in range,
/// manifold edges — and a garment can pass every one of those checks and still be unwearable. The
/// questions that decide whether a piece of clothing is finished are different ones: does it clip
/// through the body when it is worn, can it be textured, will it move with the skeleton, is it
/// affordable. Structure is included because it matters too, but it is one line of five.
///
/// IT REPORTS, IT DOES NOT REFUSE. Every number here has a legitimate reason to be non-zero. A
/// garment SHOULD have openings — a T-shirt has four — and calling a neck hole an error would teach
/// people to fear the thing that makes it a shirt. Clipping is the one that is nearly always a
/// fault, and even that is a judgement about how deep and how much rather than a boolean. So this
/// hands back counts and depths for somebody to read, and says which of them usually mean trouble.
/// </summary>
public sealed class GarmentReport
{
public string Name = "";
public int Faces;
public int Triangles;
/// <summary>Vertices sitting INSIDE a body rather than on or outside it — the garment clipping
/// through whoever is wearing it. The number that most often means real trouble.</summary>
public int Clipping;
/// <summary>How far the worst of them is in, in inches. A hundredth of an inch is the solver
/// resting on the surface; a quarter of an inch is a shoulder through a sleeve.</summary>
public float DeepestClip;
/// <summary>Where they are, one point per clipping vertex, so a viewport can mark them.</summary>
public List<Vec3> ClipPoints = new();
/// <summary>How deep each of <see cref="ClipPoints"/> is, in inches, in the same order.</summary>
public List<float> ClipDepths = new();
/// <summary>Faces with no area — invisible, and a source of broken normals and bad lighting
/// downstream. Always a fault.</summary>
public int Degenerate;
/// <summary>Boundary loops: the holes a garment is MEANT to have. A T-shirt has four — neck,
/// hem, two cuffs. Reported so the number can be recognised, never as an error.</summary>
public int Openings;
public bool HasUVs;
public bool HasWeights;
/// <summary>Structural problems, straight from MeshValidator. These are faults.</summary>
public List<string> Structure = new();
/// <summary>Everything worth saying, in the order somebody reading it would want it — worst
/// first, then the merely notable, then the reassuring.</summary>
public IEnumerable<string> Lines()
{
if ( Clipping > 0 )
yield return $"{Clipping} {(Clipping == 1 ? "vertex clips" : "vertices clip")} through the body, "
+ $"deepest {DeepestClip:0.##} in. Raise Looseness or Clearance, or shrinkwrap it back out.";
if ( Degenerate > 0 )
yield return $"{Degenerate} face{(Degenerate == 1 ? " has" : "s have")} no area. "
+ "Merge by distance will usually take them out.";
foreach ( var problem in Structure )
yield return problem;
if ( !HasWeights )
yield return "No skin weights - it will not move with the body. Copy weights from the body you cut it from.";
if ( !HasUVs )
yield return "No UVs - it cannot be textured yet.";
yield return $"{Triangles} triangles, {Openings} opening{(Openings == 1 ? "" : "s")}"
+ $"{(HasUVs ? ", UVs" : "")}{(HasWeights ? ", weighted" : "")}.";
}
/// <summary>Nothing here is a fault. Openings and triangle count are not faults.</summary>
public bool Clean => Clipping == 0 && Degenerate == 0 && Structure.Count == 0 && HasUVs && HasWeights;
public override string ToString() => string.Join( " ", Lines() );
}
/// <summary>Check a garment against the bodies wearing it. See <see cref="GarmentReport"/>.</summary>
public static class GarmentCheck
{
/// <summary>How far inside a surface counts as clipping rather than as resting on it. The
/// drape solver settles vertices ON the body by design, and a tolerance smaller than its own
/// resting error would report every well-fitted garment as broken.</summary>
public const float ClipTolerance = 0.01f;
public static GarmentReport Run( PolyMesh garment, IReadOnlyList<PolyMesh> bodies )
{
var report = new GarmentReport();
if ( garment is null )
return report;
report.Faces = garment.FaceCount;
report.HasWeights = garment.IsRigged;
foreach ( var face in garment.Faces )
{
report.Triangles += Math.Max( 0, face.Count - 2 );
if ( face.UVs is { Length: > 0 } && face.UVs.Any( uv => uv.x != 0f || uv.y != 0f ) )
report.HasUVs = true;
if ( FaceArea( garment, face ) <= 1e-9f )
report.Degenerate++;
}
var validation = MeshValidator.Validate( garment );
report.Structure.AddRange( validation.Errors );
report.Openings = BoundaryLoops( garment );
// The same signed-distance test the fitter uses to push a garment out - see
// GarmentFit.PushOut. Reusing the idiom matters: a check that disagreed with the solver
// about which side of a surface a point is on would report faults the fitter cannot fix.
foreach ( var body in bodies ?? Array.Empty<PolyMesh>() )
{
if ( body is null || body.FaceCount == 0 || ReferenceEquals( body, garment ) )
continue;
var tree = MeshBVH.Build( body );
foreach ( var p in garment.Positions )
{
if ( tree.NearestSurface( body, p, 1e6f ) is not { } hit )
continue;
var depth = -Vec3.Dot( p - hit.Point, hit.Normal );
if ( depth <= ClipTolerance )
continue;
report.Clipping++;
report.DeepestClip = Math.Max( report.DeepestClip, depth );
report.ClipPoints.Add( p );
report.ClipDepths.Add( depth );
}
}
return report;
}
static float FaceArea( PolyMesh mesh, Face face )
{
if ( face.Count < 3 )
return 0f;
var area = Vec3.Zero;
var origin = mesh.Positions[face.Indices[0]];
for ( var i = 1; i + 1 < face.Count; i++ )
{
var a = mesh.Positions[face.Indices[i]] - origin;
var b = mesh.Positions[face.Indices[i + 1]] - origin;
area += Vec3.Cross( a, b );
}
return area.Length * 0.5f;
}
/// <summary>
/// How many separate holes the mesh has, by walking the boundary edges into loops.
///
/// LOOPS, NOT EDGES. MeshValidator already counts boundary edges, and that number is useless
/// here: a T-shirt's four openings are a few hundred edges, which says nothing a person can
/// check. Four is a number you can recognise as right.
/// </summary>
static int BoundaryLoops( PolyMesh mesh )
{
var next = new Dictionary<int, List<int>>();
foreach ( var (key, faces) in mesh.BuildEdgeFaces() )
{
if ( faces.Count != 1 )
continue;
if ( !next.TryGetValue( key.A, out var a ) )
next[key.A] = a = new List<int>();
if ( !next.TryGetValue( key.B, out var b ) )
next[key.B] = b = new List<int>();
a.Add( key.B );
b.Add( key.A );
}
var seen = new HashSet<int>();
var loops = 0;
foreach ( var start in next.Keys )
{
if ( !seen.Add( start ) )
continue;
loops++;
// Flood the connected run of boundary vertices. A figure-of-eight boundary counts once,
// which is the honest answer - it is one hole pinched, not two.
var stack = new Stack<int>();
stack.Push( start );
while ( stack.Count > 0 )
{
foreach ( var n in next[stack.Pop()] )
{
if ( seen.Add( n ) )
stack.Push( n );
}
}
}
return loops;
}
}
/// <summary>Where a trim goes, and what it is.</summary>
public enum TrimStyle
{
/// <summary>A gathered strip: longer than the edge it hangs from, so it waves. Frills, ruffles,
/// a gathered hem.</summary>
Frill,
/// <summary>A flat band following the edge. Ribbon, binding, a cuff band, a waistband.</summary>
Ribbon,
/// <summary>Folded rather than gathered - the wave is angular instead of round. Pleats, a
/// box-pleated hem, an accordion cuff.</summary>
Pleat,
/// <summary>Cut into separate hanging strips. Fringe, tassels, a shredded hem.</summary>
Fringe,
}
/// <summary>
/// Frills, ruffles, ribbons, pleats and fringe: the details that make a garment a piece of FASHION
/// rather than a fitted bag.
///
/// THEY HANG OFF THE OPENINGS, and that is the whole idea. A garment already has exactly the edges
/// trim belongs on - the hem, the collar, the cuffs - because they are its boundary loops, the holes
/// it is made to have. Nothing has to be selected, drawn or marked: the shape of the garment already
/// says where a frill could go, so asking for one is choosing which opening and how much.
///
/// LACE IS NOT HERE, DELIBERATELY. Lace is holes in cloth, and holes in cloth are an alpha-masked
/// material - that is how every shipped game does it, because modelling the mesh of a lace edging
/// costs thousands of triangles to render something a 512px texture renders better. Put lace on the
/// garment as a material with a cut-out; use a Frill for the shape the lace edging follows.
///
/// GATHERED, NOT SCALED. A real frill is a strip of cloth LONGER than the edge it is sewn to, forced
/// to fit by being bunched. That is why it waves, and why the waves get deeper further from the seam
/// while the seam itself stays smooth. So this displaces along the surface normal by an amount that
/// grows with the distance hung, rather than making a wavy ring and pulling it down - which looks
/// like a cake decoration and not like cloth.
/// </summary>
public static class GarmentTrim
{
public sealed class Options
{
public TrimStyle Style = TrimStyle.Frill;
/// <summary>How far the trim hangs from the edge, in inches.</summary>
public float Width = 1.2f;
/// <summary>How much longer than its edge the strip is, 0 flat to 1 heavily bunched. What
/// makes a frill a frill.</summary>
public float Gather = 0.5f;
/// <summary>How many waves around the whole opening. More waves, finer gathers.</summary>
public int Waves = 24;
/// <summary>Rows of quads from the seam to the free edge. One is enough for a ribbon; a deep
/// frill wants four or five so the wave has somewhere to develop.</summary>
public int Rows = 3;
/// <summary>For Fringe: what fraction of each strip's slot is cut away.</summary>
public float Gap = 0.4f;
public int MaterialSlot = 0;
}
public sealed class Result
{
public PolyMesh Mesh = new();
public int Openings;
public string Problem;
}
/// <summary>
/// Build trim along some of a mesh's boundary loops.
///
/// <paramref name="which"/> picks loops by index into the order OrderedBoundaryLoops returns
/// them in, which is by height, lowest first - so on a shirt 0 is the hem and the last is the
/// collar. Null or empty means every opening.
/// </summary>
public static Result Build( PolyMesh garment, IReadOnlyList<int> which, Options o )
{
var result = new Result();
if ( garment is null || garment.FaceCount == 0 )
{
result.Problem = "there is no garment to trim";
return result;
}
var loops = OrderedBoundaryLoops( garment );
if ( loops.Count == 0 )
{
result.Problem = "this garment has no openings - trim hangs from an edge, and a closed "
+ "shape has none";
return result;
}
var normals = garment.ComputeVertexNormals();
var rows = Math.Clamp( o.Rows, 1, 12 );
for ( var li = 0; li < loops.Count; li++ )
{
if ( which is { Count: > 0 } && !which.Contains( li ) )
continue;
if ( loops[li].Count < 3 )
continue;
AddStrip( result.Mesh, garment, loops[li], normals, rows, o );
result.Openings++;
}
if ( result.Openings == 0 )
result.Problem = $"none of this garment's {loops.Count} openings were chosen";
return result;
}
static void AddStrip( PolyMesh into, PolyMesh garment, List<int> ring, Vec3[] normals, int rows,
Options o )
{
var n = ring.Count;
// Arc length around the ring, so the wave count means the same thing whether the ring is a
// cuff or a hem and whether its vertices are evenly spaced or not. Spacing waves by INDEX
// instead puts every wave of a hem into the densest quarter of it.
var arc = new float[n + 1];
for ( var i = 0; i < n; i++ )
{
var a = garment.Positions[ring[i]];
var b = garment.Positions[ring[(i + 1) % n]];
arc[i + 1] = arc[i] + (b - a).Length;
}
var total = arc[n];
if ( total <= 1e-5f )
return;
var centre = Vec3.Zero;
foreach ( var v in ring )
centre += garment.Positions[v];
centre /= n;
// Which way the trim hangs: the direction the cloth would carry on in if the garment did not
// stop at its own hem. Taken from the edge rather than as "straight down", so a cuff's frill
// runs on down the arm and a collar's stands off the neck.
var away = new Vec3[n];
for ( var i = 0; i < n; i++ )
{
var p = garment.Positions[ring[i]];
var prev = garment.Positions[ring[(i - 1 + n) % n]];
var next = garment.Positions[ring[(i + 1) % n]];
var tangent = (next - prev).Normal;
var outward = Vec3.Cross( tangent, normals[ring[i]] );
if ( outward.LengthSquared < 1e-8f )
outward = p - centre;
outward = outward.Normal;
// The cross product's sign flips with the ring's winding, which is not something a
// caller should have to know about. Point it away from the ring's own centre.
if ( Vec3.Dot( outward, p - centre ) < 0f )
outward = -outward;
away[i] = outward;
}
var waves = Math.Max( 1, o.Waves );
var gather = Math.Clamp( o.Gather, 0f, 1f );
var width = Math.Max( 0f, o.Width );
// Row 0 is the seam: it sits exactly on the garment's own edge, unmoved, so the trim is
// attached rather than floating near it.
var grid = new int[rows + 1][];
for ( var r = 0; r <= rows; r++ )
{
grid[r] = new int[n];
var t = r / (float)rows;
for ( var i = 0; i < n; i++ )
{
var p = garment.Positions[ring[i]];
var s = arc[i] / total;
var swing = o.Style switch
{
TrimStyle.Frill => MathF.Sin( s * MathF.Tau * waves ),
TrimStyle.Pleat => Triangle( s * waves ),
_ => 0f,
};
// The gather grows with distance from the seam, which is what keeps the seam itself
// from rippling. A ribbon has no gather at all and stays a flat band.
var offset = away[i] * (width * t)
+ normals[ring[i]] * (swing * gather * width * 0.6f * t);
grid[r][i] = into.Positions.Count;
into.Positions.Add( p + offset );
}
}
var fringe = o.Style == TrimStyle.Fringe;
var keep = Math.Clamp( 1f - o.Gap, 0.05f, 1f );
for ( var r = 0; r < rows; r++ )
{
for ( var i = 0; i < n; i++ )
{
var j = (i + 1) % n;
// Fringe is the same strip with most of it cut away: whole slots are dropped rather
// than narrowed, so what is left hangs as separate tassels and not as a comb.
if ( fringe && r > 0 )
{
var phase = arc[i] / total * waves;
if ( phase - MathF.Floor( phase ) > keep )
continue;
}
into.Faces.Add( new Face( new[] { grid[r][i], grid[r][j], grid[r + 1][j], grid[r + 1][i] } )
{
Material = o.MaterialSlot,
} );
}
}
}
/// <summary>A 0..1 sawtooth as a -1..1 triangle wave: a pleat's fold, angular where a frill's
/// is round.</summary>
static float Triangle( float x )
{
var f = x - MathF.Floor( x );
return f < 0.5f ? f * 4f - 1f : 3f - f * 4f;
}
/// <summary>
/// Every boundary loop as an ORDERED ring of vertices, lowest first.
///
/// ORDERED IS THE WHOLE JOB. GarmentCheck only has to COUNT openings, so it floods them; trim
/// has to walk one in sequence to lay quads along it. Height ordering is what makes "opening 0"
/// mean the hem on every garment rather than whichever vertex happened to be found first.
/// </summary>
public static List<List<int>> OrderedBoundaryLoops( PolyMesh mesh )
{
var neighbours = new Dictionary<int, List<int>>();
foreach ( var (key, faces) in mesh.BuildEdgeFaces() )
{
if ( faces.Count != 1 )
continue;
if ( !neighbours.TryGetValue( key.A, out var a ) )
neighbours[key.A] = a = new List<int>();
if ( !neighbours.TryGetValue( key.B, out var b ) )
neighbours[key.B] = b = new List<int>();
a.Add( key.B );
b.Add( key.A );
}
var loops = new List<List<int>>();
var used = new HashSet<int>();
foreach ( var start in neighbours.Keys )
{
if ( used.Contains( start ) )
continue;
var loop = new List<int>();
var current = start;
var previous = -1;
// Walk the ring. A boundary vertex normally has exactly two boundary neighbours; where
// it has more the loop is pinched, and taking the first unused one keeps the walk going
// rather than abandoning the opening.
while ( current >= 0 && used.Add( current ) )
{
loop.Add( current );
var next = -1;
foreach ( var candidate in neighbours[current] )
{
if ( candidate == previous || used.Contains( candidate ) )
continue;
next = candidate;
break;
}
previous = current;
current = next;
}
if ( loop.Count >= 3 )
loops.Add( loop );
}
loops.Sort( ( a, b ) => Height( mesh, a ).CompareTo( Height( mesh, b ) ) );
return loops;
}
static float Height( PolyMesh mesh, List<int> loop )
{
var z = 0f;
foreach ( var v in loop )
z += mesh.Positions[v].z;
return z / Math.Max( 1, loop.Count );
}
}
/// <summary>
/// Turn a garment into an s&box `.clothing` definition: the record that makes a mesh into a
/// wearable item rather than a prop.
///
/// THE STEP THIS TOOL STOPPED ONE SHORT OF. Effigy could already compile a garment to a skinned
/// `.vmdl`, and a `.vmdl` is not clothing. s&box dresses a citizen from a Clothing resource,
/// which names the model and then answers three questions the mesh cannot: what KIND of garment it
/// is, which body slots it OCCUPIES so two hats do not both get worn, and which parts of the body it
/// HIDES so the chest does not poke through the shirt. Without it a finished garment cannot be put
/// on a character at all, and the modelling was the easy part of making clothes.
///
/// THE SLOTS COME FROM THE RECIPE, not from the garment's name. The recipe already says exactly
/// which regions of the body it covers - that is what it IS - so a long sleeve claims the wrists and
/// a T-shirt does not, and adjusting Sleeve moves the answer with it. Deriving them from the name
/// would be a second, parallel description of the same garment, and the two would drift apart the
/// first time somebody dragged a slider.
///
/// WRITTEN AS TEXT, IN THE KERNEL. A Clothing resource is JSON, so it needs no engine to produce and
/// can be tested headlessly - which matters more here than usual, because the failure mode of a
/// wrong slot name is an asset that compiles, loads, and silently does not work.
/// </summary>
public static class ClothingDefinition
{
/// <summary>What a garment covers, in s&box's own slot vocabulary. Anything not listed maps
/// to nothing, which is the right answer for Neck.</summary>
public static IEnumerable<string> SlotsFor( GarmentRecipe recipe )
{
var slots = new List<string>();
void Add( string slot )
{
if ( !slots.Contains( slot ) )
slots.Add( slot );
}
// A span tagged for one side claims only that side's slots. That is the whole reason the
// side is on the span: a one-sleeved top that claimed both arms would stop you wearing
// anything on the bare one.
void Sided( int side, string left, string right )
{
if ( side >= 0 )
Add( left );
if ( side <= 0 )
Add( right );
}
foreach ( var (region, from, to, side) in recipe.Spans )
{
switch ( region )
{
case BodyRegion.Torso:
Add( "Chest" );
break;
case BodyRegion.Hips:
Add( "Groin" );
Add( "Waist" );
break;
case BodyRegion.UpperArm:
Sided( side, "LeftArm", "RightArm" );
break;
case BodyRegion.LowerArm:
Sided( side, "LeftArm", "RightArm" );
// Only a sleeve that actually reaches the wrist claims it. A three-quarter
// sleeve stopping at the forearm must leave the wrist free for a watch.
if ( to > 0.9f )
Sided( side, "LeftWrist", "RightWrist" );
break;
case BodyRegion.Hand:
Sided( side, "LeftHand", "RightHand" );
break;
case BodyRegion.UpperLeg:
Sided( side, "LeftThigh", "RightThigh" );
if ( to > 0.9f )
Sided( side, "LeftKnee", "RightKnee" );
break;
case BodyRegion.LowerLeg:
Sided( side, "LeftKnee", "RightKnee" );
Sided( side, "LeftShin", "RightShin" );
break;
case BodyRegion.Foot:
Sided( side, "LeftFoot", "RightFoot" );
break;
case BodyRegion.Head:
Add( "HeadTop" );
// A hat that comes down past the ears covers the lower head too - the
// difference between a cap and a balaclava, and what stops both being wearable
// at once.
if ( from < 0.35f )
Add( "HeadBottom" );
break;
}
}
return slots;
}
/// <summary>
/// Which parts of the BODY to stop drawing underneath, in s&box's BodyGroups vocabulary.
///
/// NOT THE SAME QUESTION AS THE SLOTS, and conflating them is how clothing ends up with skin
/// poking through it. Slots are about what else can be worn; this is about the body being drawn
/// inside the cloth. It is deliberately conservative - only whole groups the garment substantially
/// covers - because hiding a limb a garment only half covers leaves a character with no forearm.
/// </summary>
public static IEnumerable<string> HideBodyFor( GarmentRecipe recipe )
{
var hide = new List<string>();
void Add( string group )
{
if ( !hide.Contains( group ) )
hide.Add( group );
}
foreach ( var (region, from, to, _) in recipe.Spans )
{
switch ( region )
{
case BodyRegion.Torso when to > 0.9f:
Add( "Chest" );
break;
// Legs come off only for something that reaches the ankle. Shorts hide nothing:
// the leg below them is the leg you are meant to see.
case BodyRegion.LowerLeg when to > 0.9f:
Add( "Legs" );
break;
case BodyRegion.Hand:
Add( "Hands" );
break;
case BodyRegion.Foot:
Add( "Feet" );
break;
}
}
return hide;
}
/// <summary>The s&box ClothingCategory a recipe belongs in. Named from the enum this engine
/// actually has — see the effigy_clothing_probe console command.</summary>
public static string CategoryFor( GarmentRecipe recipe ) => recipe?.Name switch
{
"T-shirt" => "TShirt",
"Long sleeve" => "Shirt",
"Tank top" => "Vest",
"Trousers" => "Trousers",
"Shorts" => "Shorts",
"Beanie" => "HatBeanie",
"Gloves" => "Gloves",
"Socks" => "Socks",
"Bodysuit" => "Fullbody",
_ => "Tops",
};
/// <summary>
/// The `.clothing` file's text.
///
/// Only the fields that mean something for a garment made here are written. The rest - human
/// skin overrides, alternate male and female models, tint gradients, a Steam item id - are
/// s&box's own defaults, and writing guesses at them would be claiming a garment supports
/// things it has never been tested with.
/// </summary>
/// <param name="modelPath">The compiled model, as an asset path: models/effigy/shirt.vmdl.</param>
public static string Write( GarmentRecipe recipe, string modelPath, string title,
string subtitle = null )
{
var slots = string.Join( ", ", SlotsFor( recipe ) );
var hide = string.Join( ", ", HideBodyFor( recipe ) );
var sb = new StringBuilder();
sb.Append( "{\n" );
sb.Append( $" \"Title\": {Json( title )},\n" );
sb.Append( $" \"Subtitle\": {Json( subtitle ?? "" )},\n" );
sb.Append( $" \"Category\": {Json( CategoryFor( recipe ) )},\n" );
sb.Append( $" \"Model\": {Json( modelPath )},\n" );
// An empty flags enum serialises as 0, not as "". s&box writes it that way itself - see any
// shipped .clothing with no SlotsOver - and a "" here is read as an unknown flag name.
sb.Append( $" \"SlotsUnder\": {(slots.Length > 0 ? Json( slots ) : "0")},\n" );
sb.Append( " \"SlotsOver\": 0,\n" );
sb.Append( $" \"HideBody\": {(hide.Length > 0 ? Json( hide ) : "0")},\n" );
sb.Append( " \"__references\": [],\n" );
sb.Append( " \"__version\": 0\n" );
sb.Append( "}\n" );
return sb.ToString();
}
/// <summary>
/// Several garments as the one recipe their shared model wears.
///
/// ONE MODEL, ONE DEFINITION. Compile .vmdl bakes every garment in the document into a single
/// model, so a shirt and trousers made together are one item and must claim both sets of slots -
/// a .clothing that only named the shirt's would let a second pair of trousers go on over the
/// first. The category is the first garment's: s&box has exactly one per item, and the first
/// thing added is the best guess at what the item is.
/// </summary>
public static GarmentRecipe Combine( IEnumerable<GarmentRecipe> recipes )
{
GarmentRecipe merged = null;
foreach ( var recipe in recipes )
{
if ( recipe is null )
continue;
merged ??= new GarmentRecipe { Name = recipe.Name };
merged.Spans.AddRange( recipe.Spans );
}
return merged;
}
/// <summary>A JSON string literal. Small by design - this writes four known fields, and pulling
/// in a serialiser for them would put an engine dependency in the kernel.</summary>
static string Json( string value )
{
if ( value is null )
return "null";
var sb = new StringBuilder( value.Length + 2 );
sb.Append( '"' );
foreach ( var c in value )
{
switch ( c )
{
case '"': sb.Append( "\\\"" ); break;
case '\\': sb.Append( "\\\\" ); break;
case '\n': sb.Append( "\\n" ); break;
case '\r': sb.Append( "\\r" ); break;
case '\t': sb.Append( "\\t" ); break;
default:
if ( c < 0x20 )
sb.Append( "\\u" ).Append( ((int)c).ToString( "x4" ) );
else
sb.Append( c );
break;
}
}
sb.Append( '"' );
return sb.ToString();
}
}
/// <summary>
/// A smooth stand-in body to cut a garment from, for a wearer that is not a skin.
///
/// WHY A GARMENT NEEDS ONE. GarmentFit lifts the cloth straight off the wearer's own surface,
/// which is exactly right for a skin like Citizen's and exactly wrong for a body built from
/// parts. Camhead is struts, plates and cables with air between them; lifted off that, a
/// T-shirt came out as a tight shell over every bolt, and the drape had nothing to hang across
/// the gaps. A shirt on a robot is a shirt on the robot's SILHOUETTE.
///
/// ONE CONVEX HULL PER REGION AND SIDE - the torso, the hips, each upper arm, each forearm - out
/// of the vertices nearest that region's bones. Convex, so it spans every gap; per region, so
/// the armpit and the crotch stay open instead of the whole body becoming one blob. The hulls
/// overlap at the joints, and Fit already drops faces buried inside another closed body, which
/// is what turns overlapping hulls into one clean outer surface.
///
/// SUBDIVIDED LINEARLY, not smoothed. Catmull-Clark would round the hull inward and let the
/// corners of the real parts poke through the cloth; midpoint splits keep the shape and only add
/// the vertices the drape needs to have something to bend.
/// </summary>
public static class GarmentEnvelope
{
/// <summary>How many separate pieces a mesh is made of. A skin is one or a handful (a head, a
/// body, two eyes); a kitbashed robot is hundreds.</summary>
public static int ShellCount( PolyMesh mesh )
{
var parent = new int[mesh.VertexCount];
for ( var i = 0; i < parent.Length; i++ )
parent[i] = i;
int Find( int x )
{
while ( parent[x] != x )
x = parent[x] = parent[parent[x]];
return x;
}
foreach ( var face in mesh.Faces )
for ( var c = 1; c < face.Count; c++ )
parent[Find( face.Indices[c] )] = Find( face.Indices[0] );
var roots = new HashSet<int>();
foreach ( var face in mesh.Faces )
if ( face.Count > 0 )
roots.Add( Find( face.Indices[0] ) );
return roots.Count;
}
/// <summary>Should a garment be cut from an envelope instead of the surface? More than this
/// many pieces is a body built from parts. Citizen is under ten.</summary>
public const int PartsThreshold = 24;
public static bool IsKitbashed( IEnumerable<PolyMesh> bodies ) =>
bodies.Sum( ShellCount ) > PartsThreshold;
/// <summary>The stand-in: one closed, subdivided hull per body region and side.</summary>
public static List<(BodyRegion Region, PolyMesh Mesh)> Build( IReadOnlyList<PolyMesh> bodies, BodyRegions.Map map, float edge = 1.5f )
{
var groups = new Dictionary<(BodyRegion, int), List<Vec3>>();
foreach ( var body in bodies )
{
foreach ( var p in body.Positions )
{
var (region, _) = map.Locate( p );
if ( region == BodyRegion.None )
continue;
var central = region is BodyRegion.Head or BodyRegion.Neck or BodyRegion.Torso or BodyRegion.Hips;
var key = (region, central ? 0 : p.y > 0 ? 1 : -1);
if ( !groups.TryGetValue( key, out var list ) )
groups[key] = list = new List<Vec3>();
list.Add( p );
}
}
// THE TORSO HANGS. Cloth falls from the widest thing it rests on - shoulders, chest - and
// does not follow the body back in at the waist, which is the whole difference between a
// shirt and a wetsuit. So the torso's hull is dropped straight down to the bottom of the
// hips: a column as wide as the chest, and the drape has something to hang from rather
// than a waist to crumple into.
if ( groups.TryGetValue( (BodyRegion.Torso, 0), out var torso ) && groups.TryGetValue( (BodyRegion.Hips, 0), out var hips ) )
{
var floor = hips.Min( p => p.z );
// Only the chest between the arms. Dropped from the shoulder points too, the column is
// as wide as the shoulders all the way down, swallows an A-posed upper arm, and the
// sleeves - the faces of the arm hulls inside it - are cut away with it.
var inner = groups.Where( g => g.Key.Item1 == BodyRegion.UpperArm )
.SelectMany( g => g.Value ).Select( p => MathF.Abs( p.y ) ).DefaultIfEmpty( float.MaxValue ).Min();
var dropped = torso.Where( p => MathF.Abs( p.y ) < inner )
.Select( p => new Vec3( p.x, p.y, floor ) ).ToList();
torso.AddRange( dropped );
}
var hulls = new List<(BodyRegion, PolyMesh)>();
foreach ( var ((groupRegion, _), points) in groups )
{
if ( points.Count < 4 )
continue;
PolyMesh hull;
try
{
hull = ConvexHull.ToMesh( Extremes( points ), 1e-4f );
}
catch ( Exception )
{
// Flat or degenerate - a single plate on its own. Nothing to wrap.
continue;
}
if ( hull is null || hull.FaceCount == 0 )
continue;
hulls.Add( (groupRegion, Refine( hull, edge )) );
}
return hulls;
}
/// <summary>
/// The points furthest along a few hundred directions spread evenly over the sphere.
///
/// ConvexHull is incremental and sized for CAD bodies of a few hundred vertices; a robot's
/// torso is thousands, nearly all of them inside, and hulling them all took minutes. The
/// extremes are the hull's own corners to within a fraction of an inch, which is far closer
/// than cloth sitting a clearance off it can tell apart.
/// </summary>
static List<Vec3> Extremes( List<Vec3> points, int directions = 320 )
{
if ( points.Count <= directions )
return points;
var keep = new HashSet<int>();
var golden = MathF.PI * (3f - MathF.Sqrt( 5f ));
for ( var d = 0; d < directions; d++ )
{
var z = 1f - 2f * (d + 0.5f) / directions;
var r = MathF.Sqrt( MathF.Max( 0f, 1f - z * z ) );
var dir = new Vec3( r * MathF.Cos( golden * d ), r * MathF.Sin( golden * d ), z );
var best = 0;
var bestDot = float.MinValue;
for ( var i = 0; i < points.Count; i++ )
{
var dot = Vec3.Dot( points[i], dir );
if ( dot > bestDot )
{
bestDot = dot;
best = i;
}
}
keep.Add( best );
}
return keep.Select( i => points[i] ).ToList();
}
/// <summary>Triangulate, then split every triangle four ways until the longest edge is under
/// <paramref name="edge"/>. Uniform on purpose: every triangle splits every round, so shared
/// edges always split together and the hull stays closed - a split that followed each edge's
/// own length would leave T-junctions wherever a long face met a short one.</summary>
public static PolyMesh Refine( PolyMesh mesh, float edge, int maxTriangles = 12000 )
{
var positions = new List<Vec3>( mesh.Positions );
var tris = new List<(int A, int B, int C)>();
foreach ( var face in mesh.Faces )
for ( var c = 1; c + 1 < face.Count; c++ )
tris.Add( (face.Indices[0], face.Indices[c], face.Indices[c + 1]) );
var limit = MathF.Max( edge, 0.05f );
float Longest() => tris.Count == 0 ? 0f : tris.Max( t => MathF.Max( (positions[t.A] - positions[t.B]).Length,
MathF.Max( (positions[t.B] - positions[t.C]).Length, (positions[t.C] - positions[t.A]).Length ) ) );
while ( Longest() > limit && tris.Count * 4 <= maxTriangles )
{
var mids = new Dictionary<EdgeKey, int>();
var next = new List<(int, int, int)>( tris.Count * 4 );
int Mid( int a, int b )
{
var key = new EdgeKey( a, b );
if ( !mids.TryGetValue( key, out var m ) )
{
m = positions.Count;
positions.Add( (positions[a] + positions[b]) * 0.5f );
mids[key] = m;
}
return m;
}
foreach ( var (a, b, c) in tris )
{
var ab = Mid( a, b );
var bc = Mid( b, c );
var ca = Mid( c, a );
next.Add( (a, ab, ca) );
next.Add( (ab, b, bc) );
next.Add( (ca, bc, c) );
next.Add( (ab, bc, ca) );
}
tris = next;
}
var result = new PolyMesh();
foreach ( var p in positions )
result.AddVertex( p );
foreach ( var (a, b, c) in tris )
result.AddFace( new[] { a, b, c }, null, 0 );
return result;
}
}