Effigy/GarmentPoses.cs

Static utility defining a set of extreme test poses for garments and applying them to a skeleton. It declares Turn and Pose types, a list of named poses, logic to infer bone side from names, and functions to apply rotations to skeleton bones (including spreading torso bends along a chain).

Native Interop
using System;
using System.Collections.Generic;
using System.Linq;

namespace Effigy;

/// <summary>
/// The poses a garment is tested in before it ships: the ones that find a shoulder through a
/// sleeve, a thigh through a trouser leg, a hem that rides up when the wearer sits.
///
/// WHY POSES AND NOT AN ANIMATION. A garment fitted in the bind pose fits in the bind pose, and
/// nothing about that says whether it still does with the arms up. Every clothing tool worth
/// the name previews the extremes - Marvelous Designer's "test movement", CLO's pose library -
/// because the extremes are where clipping lives, and a walk cycle never reaches them. Eight
/// still poses, each one an extreme, found the same faults a clip would and are a click each.
///
/// BY REGION, NOT BY BONE NAME. A pose says "raise the upper arms 130 degrees"; which bone that
/// is comes from <see cref="BodyRegions.Classify"/>, so the same eight poses bend Citizen, a
/// Mixamo import and a rig somebody named in English. Only the first bone of a region's chain
/// is turned (a twist bone, a second spine link follow their parent as they would in a graph),
/// except the torso, which spreads its bend along the spine so it curves rather than hinges.
///
/// WORLD AXES, in the body's frame: +x forward, +y left, +z up. A pose is written for a body
/// standing upright and facing +x, which is what every wearer in Effigy is, and the rotation is
/// applied about the bone's own head so the joint stays put.
/// </summary>
public static class GarmentPoses
{
	/// <summary>One joint's turn: which region, which side (+1 left, -1 right, 0 both), about
	/// which world axis, by how many degrees. <see cref="Mirror"/> flips the angle for the right
	/// side, for the turns that are symmetric (raising both arms) rather than shared (leaning).</summary>
	public readonly record struct Turn( BodyRegion Region, int Side, Vec3 Axis, float Degrees, bool Mirror = false );

	public sealed class Pose
	{
		public string Name;
		public string Tip;
		public Turn[] Turns;
	}

	static readonly Vec3 Fwd = new( 1f, 0f, 0f );
	static readonly Vec3 Left = new( 0f, 1f, 0f );
	static readonly Vec3 Up = new( 0f, 0f, 1f );

	// About +x, +degrees takes a hanging left arm (-z) out to the left (+y); mirrored, the right
	// arm goes right. About +y, -degrees takes a hanging limb forward (+x); +degrees takes it back.
	// About +z, +degrees turns the body to its left.
	public static readonly IReadOnlyList<Pose> All = new[]
	{
		new Pose
		{
			Name = "Arms out", Tip = "Arms straight out to the sides. Finds a sleeve that pulls off the shoulder.",
			Turns = new[] { new Turn( BodyRegion.UpperArm, 0, Fwd, 50f, Mirror: true ) },
		},
		new Pose
		{
			Name = "Arms up", Tip = "Both arms straight up. The pose that lifts a hem and splits an armpit.",
			Turns = new[] { new Turn( BodyRegion.UpperArm, 0, Fwd, 130f, Mirror: true ) },
		},
		new Pose
		{
			Name = "Reach", Tip = "Arms forward, elbows bent, like holding a wheel. Finds the back of a shoulder through a sleeve.",
			Turns = new[]
			{
				new Turn( BodyRegion.UpperArm, 0, Left, -70f ),
				new Turn( BodyRegion.LowerArm, 0, Left, -60f ),
			},
		},
		new Pose
		{
			Name = "Bend", Tip = "Bent forward at the waist. The back of a shirt has to stretch; the hem rides up.",
			Turns = new[] { new Turn( BodyRegion.Torso, 0, Left, 45f ) },
		},
		new Pose
		{
			Name = "Twist", Tip = "Upper body turned to the left. Finds a waist that does not follow.",
			Turns = new[] { new Turn( BodyRegion.Torso, 0, Up, 40f ) },
		},
		new Pose
		{
			Name = "Sit", Tip = "Thighs forward, knees bent, as in a chair. Trousers pull at the seat and the knee.",
			Turns = new[]
			{
				new Turn( BodyRegion.UpperLeg, 0, Left, -85f ),
				new Turn( BodyRegion.LowerLeg, 0, Left, 85f ),
			},
		},
		new Pose
		{
			Name = "Stride", Tip = "One leg forward, one back, mid-step. Finds the crotch and the back of the thigh.",
			Turns = new[]
			{
				new Turn( BodyRegion.UpperLeg, +1, Left, -40f ),
				new Turn( BodyRegion.LowerLeg, +1, Left, 30f ),
				new Turn( BodyRegion.UpperLeg, -1, Left, 30f ),
			},
		},
		new Pose
		{
			Name = "Crouch", Tip = "Down on the haunches, arms forward. Everything at once.",
			Turns = new[]
			{
				new Turn( BodyRegion.UpperLeg, 0, Left, -100f ),
				new Turn( BodyRegion.LowerLeg, 0, Left, 120f ),
				new Turn( BodyRegion.Torso, 0, Left, 30f ),
				new Turn( BodyRegion.UpperArm, 0, Left, -60f ),
			},
		},
	};

	/// <summary>
	/// Which side of the body a bone is on: +1 left, -1 right, 0 for the middle (a spine, a neck)
	/// or a name that does not say. Reads Citizen's <c>_L</c>/<c>_R</c>, Mixamo's <c>Left</c>/
	/// <c>Right</c>, Unreal's <c>_l</c>/<c>_r</c> and the words themselves.
	/// </summary>
	public static int SideOf( string boneName )
	{
		var n = (boneName ?? "").ToLowerInvariant();

		if ( n.Contains( "left" ) ) return +1;
		if ( n.Contains( "right" ) ) return -1;

		// A side letter is its own token: arm_upper_L, arm_upper_L_twist, upperarm_l, L_arm.
		var parts = n.Split( new[] { '_', '.', ':', ' ', '-' }, StringSplitOptions.RemoveEmptyEntries );

		if ( parts.Any( p => p == "l" ) ) return +1;
		if ( parts.Any( p => p == "r" ) ) return -1;

		return 0;
	}

	/// <summary>
	/// Bend a skeleton into a pose, in place. Call it on a skeleton at its bind pose; calling it
	/// twice bends twice. Returns how many bones were turned, so a rig Effigy cannot read (no
	/// arm it recognises) reports "nothing moved" rather than showing the bind pose as the answer.
	/// </summary>
	/// <param name="amount">How far into the pose, 0 the bind pose to 1 the whole thing. A scrub
	/// through the middle finds where a sleeve first catches, which the extreme alone does not.</param>
	public static int Apply( Skeleton skeleton, Pose pose, float amount = 1f )
	{
		if ( skeleton is null || pose?.Turns is null )
			return 0;

		amount = Math.Clamp( amount, 0f, 1f );

		var turned = 0;

		foreach ( var turn in pose.Turns )
		{
			// The chains of this region on the wanted side: each starts at a bone whose parent is
			// not in the region, and runs down through children that are.
			var roots = new List<int>();

			for ( var i = 0; i < skeleton.Count; i++ )
			{
				var bone = skeleton.Bones[i];

				if ( BodyRegions.Classify( bone.Name ) != turn.Region )
					continue;

				if ( bone.Parent >= 0 && BodyRegions.Classify( skeleton.Bones[bone.Parent].Name ) == turn.Region )
					continue;

				var side = SideOf( bone.Name );

				if ( turn.Side != 0 && side != turn.Side )
					continue;

				roots.Add( i );
			}

			foreach ( var root in roots )
			{
				var side = SideOf( skeleton.Bones[root].Name );
				var degrees = (turn.Mirror && side < 0 ? -turn.Degrees : turn.Degrees) * amount;

				// The torso is a chain that should curve: spread the bend down the links. A limb
				// hinges at its root and its twist bones ride along.
				var chain = turn.Region is BodyRegion.Torso ? Chain( skeleton, root, turn.Region ) : new List<int> { root };
				var each = degrees / chain.Count;

				foreach ( var index in chain )
				{
					Rotate( skeleton, index, turn.Axis, each );
					turned++;
				}
			}
		}

		return turned;
	}

	/// <summary>Rotate a bone about a world axis through its own head, keeping its children with
	/// it: only this bone's Local changes, and WorldBind walks the chain, so everything below
	/// follows the way a posed joint's do.</summary>
	static void Rotate( Skeleton skeleton, int index, Vec3 axis, float degrees )
	{
		var bone = skeleton.Bones[index];
		var world = skeleton.WorldBind( index );
		var spun = Xform.RotateAbout( world.Origin, axis, degrees * MathF.PI / 180f ) * world;

		bone.Local = bone.Parent < 0 ? spun : skeleton.WorldBind( bone.Parent ).Inverse * spun;
	}

	/// <summary>A bone that classifies as its parent's region but is not the next link of the
	/// chain: a clavicle off the spine, a twist bone beside the arm.</summary>
	static bool Offshoot( string name ) =>
		name.Contains( "twist", StringComparison.OrdinalIgnoreCase )
		|| name.Contains( "clavicle", StringComparison.OrdinalIgnoreCase )
		|| name.Contains( "shoulder", StringComparison.OrdinalIgnoreCase )
		|| name.Contains( "collar", StringComparison.OrdinalIgnoreCase );

	/// <summary>The bone and the line of same-region descendants under it, top down, one per
	/// level: a spine's links, not every rib hanging off them.</summary>
	static List<int> Chain( Skeleton skeleton, int root, BodyRegion region )
	{
		var chain = new List<int> { root };
		var at = root;

		while ( true )
		{
			var next = skeleton.Children( at )
				.Where( c => BodyRegions.Classify( skeleton.Bones[c].Name ) == region && !Offshoot( skeleton.Bones[c].Name ) )
				.Cast<int?>()
				.FirstOrDefault();

			if ( next is not { } n )
				return chain;

			chain.Add( n );
			at = n;
		}
	}
}