Effigy.Tests/RigTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;

namespace Effigy.Tests;

/// <summary>
/// Verification for the rigging half: skeleton maths, weight generation, weights surviving
/// subdivision, and SMD export.
///
/// The theme is the same as the geometry tests — check things that fail loudly. A rig that is
/// subtly wrong still deforms smoothly and still looks plausible; it just bends around slightly
/// the wrong place. What catches that is arithmetic: weights that must sum to 1, a bind pose that
/// must survive a round trip through Euler angles, and a binding that must still name the same
/// body after the model rebuilds underneath it.
/// </summary>
public static class RigTests
{
	public static void Run()
	{
		Section( "skeleton hierarchy and bind pose" );
		TestSkeleton();

		Section( "removing and renaming a bone" );
		TestRemoveRenameBone();

		Section( "reparenting a bone" );
		TestSetParent();

		Section( "mirroring a bone subtree" );
		TestMirrorSubtree();

		Section( "editing a bone's head/tail numerically" );
		TestSetHeadTail();

		Section( "an up-hint settles a bone's roll" );
		TestBoneRoll();

		Section( "Euler conversion round-trips" );
		TestEuler();

		Section( "auto-binding produces valid weights" );
		TestBinding();

		Section( "linear blend skinning deforms a posed rig" );
		TestDeform();

		Section( "weights survive subdivision" );
		TestWeightsThroughSubdivision();

		Section( "feature binding survives a rebuild" );
		TestFeatureBinding();

		Section( "SMD export" );
		TestSmd();

		Section( "rig: regressions from review" );
		TestReviewRegressions();
	}

	// --- skeleton -----------------------------------------------------------------------

	static Skeleton TwoBoneChain()
	{
		var s = new Skeleton();
		s.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, 0 ), new Vec3( 0, 2, 0 ) );
		s.AddBoneFromPoints( "upper", 0, new Vec3( 0, 2, 0 ), new Vec3( 0, 4, 0 ) );
		return s;
	}

	/// <summary>
	/// A chain aimed down Z, matching how Primitives builds a cylinder — it runs along Z, centred
	/// on the origin, which is the Source/s&amp;box convention.
	///
	/// Worth being explicit about: a chain that does not lie along the shape it is skinning binds
	/// every vertex to whichever bone happens to be nearest, which is a perfectly correct answer to
	/// the wrong question. Test geometry has to be aimed at the model.
	/// </summary>
	static Skeleton CylinderChain( float height = 4f )
	{
		var half = height * 0.5f;
		var s = new Skeleton();
		s.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, -half ), new Vec3( 0, 0, 0 ) );
		s.AddBoneFromPoints( "upper", 0, new Vec3( 0, 0, 0 ), new Vec3( 0, 0, half ) );
		return s;
	}

	static void TestSkeleton()
	{
		var s = TwoBoneChain();

		Check( "two bones", s.Count == 2 );
		Check( "parent recorded", s.Bones[1].Parent == 0 );
		Check( "lookup by name", s.IndexOf( "upper" ) == 1 && s.IndexOf( "nope" ) < 0 );

		// The child's local transform is relative to its parent, but its WORLD head must land back
		// on the point it was authored from. That is the whole contract of the parent chain.
		Check( "child head is world-correct", Near( s.HeadWorld( 1 ), new Vec3( 0, 2, 0 ) ),
			s.HeadWorld( 1 ).ToString() );
		Check( "child tail is world-correct", Near( s.TailWorld( 1 ), new Vec3( 0, 4, 0 ) ),
			s.TailWorld( 1 ).ToString() );
		Check( "bone length measured", MathF.Abs( s.Bones[1].Length - 2f ) < 1e-4f );
		Check( "children enumerate", s.Children( 0 ).SequenceEqual( new[] { 1 } ) );

		// A chain that is not axis-aligned is where a wrong parent composition stops being
		// invisible: with an identity-ish rig every convention agrees.
		var angled = new Skeleton();
		angled.AddBoneFromPoints( "a", -1, new Vec3( 1, 0, 0 ), new Vec3( 3, 1, 0 ) );
		angled.AddBoneFromPoints( "b", 0, new Vec3( 3, 1, 0 ), new Vec3( 4, 3, 1 ) );
		Check( "angled chain keeps world head", Near( angled.HeadWorld( 1 ), new Vec3( 3, 1, 0 ) ),
			angled.HeadWorld( 1 ).ToString() );
		Check( "angled chain keeps world tail", Near( angled.TailWorld( 1 ), new Vec3( 4, 3, 1 ) ),
			angled.TailWorld( 1 ).ToString() );

		var threw = false;
		try { angled.AddBone( "c", 7, Xform.Identity ); } catch ( ArgumentOutOfRangeException ) { threw = true; }
		Check( "a forward parent reference is refused", threw );

		threw = false;
		try { angled.AddBone( "a", -1, Xform.Identity ); } catch ( ArgumentException ) { threw = true; }
		Check( "a duplicate bone name is refused", threw );

		threw = false;
		try { angled.AddBoneFromPoints( "z", -1, Vec3.Zero, Vec3.Zero ); } catch ( ArgumentException ) { threw = true; }
		Check( "a zero-length bone is refused", threw );
	}

	/// <summary>
	/// The up-hint on AddBoneFromPoints, which is what makes a grip or muzzle bone land at a roll
	/// an animator can predict rather than one falling out of whichever world axis the bone leaned
	/// on least.
	/// </summary>
	static void TestBoneRoll()
	{
		// Aimed down +X, so the roll is genuinely free and any of the three answers below is
		// geometrically valid. That is exactly why the caller has to be able to pick one.
		var head = new Vec3( 0, 0, 0 );
		var tail = new Vec3( 4, 0, 0 );

		var up = new Skeleton();
		up.AddBoneFromPoints( "aim", -1, head, tail, new Vec3( 0, 0, 1 ) );
		var world = up.WorldBind( 0 );

		Check( "+Y still runs head to tail", Near( world.Y, new Vec3( 1, 0, 0 ) ), world.Y.ToString() );
		Check( "+Z takes the up-hint", Near( world.Z, new Vec3( 0, 0, 1 ) ), world.Z.ToString() );
		Check( "the basis stays right-handed",
			Near( Vec3.Cross( world.Y, world.Z ), world.X ), world.X.ToString() );

		// Rolled a quarter turn: same aim, different up, and the bone has to follow the hint
		// rather than snap back to a canonical answer.
		var side = new Skeleton();
		side.AddBoneFromPoints( "aim", -1, head, tail, new Vec3( 0, 1, 0 ) );
		Check( "a different hint rolls the bone", Near( side.WorldBind( 0 ).Z, new Vec3( 0, 1, 0 ) ),
			side.WorldBind( 0 ).Z.ToString() );

		// A hint need not be perpendicular — the component along the bone is projected out, so
		// "roughly upwards" is a usable thing to pass.
		var sloppy = new Skeleton();
		sloppy.AddBoneFromPoints( "aim", -1, head, tail, new Vec3( 9, 0, 1 ) );
		Check( "a hint along the bone is projected out",
			Near( sloppy.WorldBind( 0 ).Z, new Vec3( 0, 0, 1 ) ), sloppy.WorldBind( 0 ).Z.ToString() );

		// Parallel to the aim it selects nothing, and that must degrade to the old arbitrary
		// perpendicular rather than throwing or producing a zero axis — a caller passing a
		// constant up-vector for every bone in a rig will hit this on the one bone that points up.
		var degenerate = new Skeleton();
		degenerate.AddBoneFromPoints( "aim", -1, head, tail, new Vec3( 1, 0, 0 ) );
		var fallback = degenerate.WorldBind( 0 );
		var plain = new Skeleton();
		plain.AddBoneFromPoints( "aim", -1, head, tail );

		Check( "a hint along the aim falls back rather than throwing",
			Near( fallback.Z, plain.WorldBind( 0 ).Z ), fallback.Z.ToString() );
		Check( "the fallback basis is still unit length",
			MathF.Abs( fallback.Z.Length - 1f ) < 1e-4f, fallback.Z.Length.ToString() );

		// The no-hint path has to be untouched: every existing rig and every test above it was
		// built without one, and a change in that answer would move geometry nobody edited.
		var before = new Skeleton();
		before.AddBoneFromPoints( "a", -1, new Vec3( 1, 0, 0 ), new Vec3( 3, 1, 0 ) );
		Check( "no hint keeps the original axes",
			Near( before.WorldBind( 0 ).X, Vec3.Cross( new Vec3( 1, 0, 0 ),
				(new Vec3( 3, 1, 0 ) - new Vec3( 1, 0, 0 )).Normal ).Normal ) );
	}

	/// <summary>Three-bone chain: root -> mid -> tip. Deleting the middle one is the case that
	/// matters — it is the one where a naive delete would either orphan the tip or move it.</summary>
	static Skeleton ThreeBoneChain()
	{
		var s = new Skeleton();
		s.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, 0 ), new Vec3( 0, 2, 0 ) );
		s.AddBoneFromPoints( "mid", 0, new Vec3( 0, 2, 0 ), new Vec3( 0, 4, 0 ) );
		s.AddBoneFromPoints( "tip", 1, new Vec3( 0, 4, 0 ), new Vec3( 0, 5, 0 ) );
		return s;
	}

	static void TestRemoveRenameBone()
	{
		var s = ThreeBoneChain();
		var tipHeadBefore = s.HeadWorld( 2 );
		var tipTailBefore = s.TailWorld( 2 );

		s.RemoveBone( 1 ); // delete "mid"

		Check( "one bone gone", s.Count == 2 );
		Check( "root survives at 0", s.IndexOf( "root" ) == 0 );
		Check( "mid is gone", s.IndexOf( "mid" ) < 0 );

		var tipIndex = s.IndexOf( "tip" );
		Check( "tip survives", tipIndex >= 0 );
		Check( "tip re-parents to root", s.Bones[tipIndex].Parent == s.IndexOf( "root" ) );
		Check( "tip keeps its world head", Near( s.HeadWorld( tipIndex ), tipHeadBefore ),
			s.HeadWorld( tipIndex ).ToString() );
		Check( "tip keeps its world tail", Near( s.TailWorld( tipIndex ), tipTailBefore ),
			s.TailWorld( tipIndex ).ToString() );

		// A root with no parent at all — removing it should not throw reaching for Parent -1.
		var rootOnly = new Skeleton();
		rootOnly.AddBoneFromPoints( "only", -1, Vec3.Zero, new Vec3( 0, 1, 0 ) );
		rootOnly.RemoveBone( 0 );
		Check( "removing the only bone leaves an empty skeleton", rootOnly.Count == 0 );

		var threw = false;
		try { s.RemoveBone( 99 ) ; } catch ( ArgumentOutOfRangeException ) { threw = true; }
		Check( "removing an out-of-range index is refused", threw );

		// Rename.
		var chain = ThreeBoneChain();
		chain.RenameBone( 1, "elbow" );
		Check( "renamed bone is found under its new name", chain.IndexOf( "elbow" ) == 1 );
		Check( "old name is gone", chain.IndexOf( "mid" ) < 0 );

		threw = false;
		try { chain.RenameBone( 0, "elbow" ); } catch ( ArgumentException ) { threw = true; }
		Check( "renaming onto an existing name is refused", threw );

		// Renaming a bone onto ITS OWN existing name is not a collision.
		chain.RenameBone( 1, "elbow" );
		Check( "renaming onto its own name is a no-op, not an error", chain.IndexOf( "elbow" ) == 1 );

		threw = false;
		try { chain.RenameBone( 0, "  " ); } catch ( ArgumentException ) { threw = true; }
		Check( "a blank name is refused", threw );
	}

	/// <summary>
	/// The gun-rig case: three roots (root, trigger, mag) then hang the last two off the first.
	/// World poses stay put; moving the root afterwards carries the children. Parenting the other
	/// way — an earlier bone onto a later one — has to reorder the list or the parent-before-child
	/// invariant dies.
	/// </summary>
	static void TestSetParent()
	{
		var s = new Skeleton();
		s.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, 0 ), new Vec3( 0, 2, 0 ) );
		s.AddBoneFromPoints( "trigger", -1, new Vec3( 1, 0, 0 ), new Vec3( 1, 1, 0 ) );
		s.AddBoneFromPoints( "mag", -1, new Vec3( 0, 0, -1 ), new Vec3( 0, 1, -1 ) );

		var triggerHead = s.HeadWorld( 1 );
		var magTail = s.TailWorld( 2 );

		var trigger = s.SetParent( 1, 0 );
		var mag = s.SetParent( s.IndexOf( "mag" ), 0 );

		Check( "trigger hangs off root", s.Bones[trigger].Parent == 0 );
		Check( "mag hangs off root", s.Bones[mag].Parent == 0 );
		Check( "trigger kept its world head", Near( s.HeadWorld( trigger ), triggerHead ) );
		Check( "mag kept its world tail", Near( s.TailWorld( mag ), magTail ) );
		Check( "root still has two children", s.Children( 0 ).Count() == 2 );

		var noOp = s.SetParent( trigger, 0 );
		Check( "parenting to the parent it already has is a no-op", noOp == trigger );

		var threw = false;
		try { s.SetParent( 0, trigger ); } catch ( ArgumentException ) { threw = true; }
		Check( "a bone cannot parent to something that already hangs off it", threw );

		threw = false;
		try { s.SetParent( 0, 0 ); } catch ( ArgumentException ) { threw = true; }
		Check( "a bone cannot parent to itself", threw );

		// Parent the first bone onto the last: indices must move so the parent still comes first.
		var flipped = new Skeleton();
		flipped.AddBoneFromPoints( "leaf", -1, new Vec3( 3, 0, 0 ), new Vec3( 4, 0, 0 ) );
		flipped.AddBoneFromPoints( "stem", -1, Vec3.Zero, new Vec3( 0, 2, 0 ) );

		var leafHead = flipped.HeadWorld( 0 );
		var newLeaf = flipped.SetParent( 0, 1 );

		Check( "the new parent is earlier in the list than the child",
			flipped.Bones[newLeaf].Parent >= 0
			&& flipped.Bones[newLeaf].Parent < newLeaf );
		Check( "leaf kept its world head after the reorder",
			Near( flipped.HeadWorld( newLeaf ), leafHead ) );
		Check( "stem is a root", flipped.Bones[flipped.IndexOf( "stem" )].Parent == -1 );

		var unhooked = flipped.SetParent( newLeaf, -1 );
		Check( "parenting to none makes a root", flipped.Bones[unhooked].Parent == -1 );
		Check( "and the world pose is still the one that was drawn",
			Near( flipped.HeadWorld( unhooked ), leafHead ) );
	}

	/// <summary>A spine root with one arm off to the +X side — the shape mirroring an arm across
	/// the character's centreline actually has to handle.</summary>
	static Skeleton SpineWithOneArm()
	{
		var s = new Skeleton();
		s.AddBoneFromPoints( "spine", -1, new Vec3( 0, 0, 0 ), new Vec3( 0, 0, 2 ) );
		s.AddBoneFromPoints( "upper_arm_L", 0, new Vec3( 1, 0, 2 ), new Vec3( 3, 0, 2 ) );
		s.AddBoneFromPoints( "forearm_L", 1, new Vec3( 3, 0, 2 ), new Vec3( 5, 0, 1 ) );
		return s;
	}

	static void TestMirrorSubtree()
	{
		var s = SpineWithOneArm();
		var spine = s.IndexOf( "spine" );
		var upperL = s.IndexOf( "upper_arm_L" );

		var newRoot = s.MirrorSubtree( upperL, new Vec3( 1, 0, 0 ), spine );

		Check( "mirror returns a valid index", newRoot >= 0 && newRoot < s.Count );
		Check( "five bones total now", s.Count == 5 );
		Check( "_L became _R", s.Bones[newRoot].Name == "upper_arm_R" );
		Check( "mirrored bone grafts onto the requested parent", s.Bones[newRoot].Parent == spine );

		var headL = s.HeadWorld( upperL );
		var headR = s.HeadWorld( newRoot );
		Check( "X is negated", MathF.Abs( headR.x + headL.x ) < 1e-4f, $"{headL} vs {headR}" );
		Check( "Y and Z untouched by an X-normal mirror",
			MathF.Abs( headR.y - headL.y ) < 1e-4f && MathF.Abs( headR.z - headL.z ) < 1e-4f );

		var tailL = s.TailWorld( upperL );
		var tailR = s.TailWorld( newRoot );
		Check( "tail is mirrored too", Near( tailR, new Vec3( -tailL.x, tailL.y, tailL.z ) ), tailR.ToString() );

		Check( "length survives the mirror",
			MathF.Abs( s.Bones[newRoot].Length - s.Bones[upperL].Length ) < 1e-4f );

		var forearmR = s.IndexOf( "forearm_R" );
		Check( "the child came along", forearmR >= 0 );
		Check( "the child re-parents to the mirrored parent, not the original", s.Bones[forearmR].Parent == newRoot );

		var forearmL = s.IndexOf( "forearm_L" );
		Check( "the original subtree is untouched",
			Near( s.HeadWorld( forearmL ), new Vec3( 3, 0, 2 ) ) && s.Bones[forearmL].Parent == upperL );

		// Mirroring again must not collide with the name it just made.
		var second = s.MirrorSubtree( upperL, new Vec3( 1, 0, 0 ), spine );
		Check( "a second mirror gets a de-duplicated name, not a crash",
			s.Bones[second].Name != s.Bones[newRoot].Name, s.Bones[second].Name );

		// A bone with no _L/_R convention in its name.
		var plain = new Skeleton();
		plain.AddBoneFromPoints( "antenna", -1, Vec3.Zero, new Vec3( 1, 0, 0 ) );
		var mirroredPlain = plain.MirrorSubtree( 0, new Vec3( 1, 0, 0 ) );
		Check( "a name with no L/R convention gets _mirrored appended",
			plain.Bones[mirroredPlain].Name == "antenna_mirrored" );
		Check( "newParent -1 makes a new root", plain.Bones[mirroredPlain].Parent == -1 );

		var threw = false;
		try { s.MirrorSubtree( 99, new Vec3( 1, 0, 0 ) ); } catch ( ArgumentOutOfRangeException ) { threw = true; }
		Check( "mirroring an out-of-range bone is refused", threw );

		threw = false;
		try { s.MirrorSubtree( upperL, Vec3.Zero ); } catch ( ArgumentException ) { threw = true; }
		Check( "a zero-length mirror normal is refused", threw );
	}

	static void TestSetHeadTail()
	{
		var s = ThreeBoneChain(); // root(0) -> mid(1) -> tip(2)

		var rootHeadBefore = s.HeadWorld( 0 );
		var rootTailBefore = s.TailWorld( 0 );
		var midHeadBefore = s.HeadWorld( 1 );
		var tipHeadBefore = s.HeadWorld( 2 );
		var tipTailBefore = s.TailWorld( 2 );

		// A PURE TRANSLATION of the root — same direction and length, just moved — has to carry
		// every descendant by the identical rigid delta. This is not a Blender-style "connected"
		// bone that stretches to chase a moving tail: a child's Local is frozen relative to its
		// parent's BASIS, and a translation leaves that basis's rotation untouched, so the whole
		// subtree rides along exactly.
		var delta = new Vec3( 3, -1, 2 );
		s.SetHeadTail( 0, rootHeadBefore + delta, rootTailBefore + delta );

		Check( "root moved by the delta", Near( s.HeadWorld( 0 ), rootHeadBefore + delta ), s.HeadWorld( 0 ).ToString() );
		Check( "root's length is unchanged by a pure translation", MathF.Abs( s.Bones[0].Length - 2f ) < 1e-4f );
		Check( "mid carried by the same rigid delta", Near( s.HeadWorld( 1 ), midHeadBefore + delta ), s.HeadWorld( 1 ).ToString() );
		Check( "tip carried by the same rigid delta", Near( s.HeadWorld( 2 ), tipHeadBefore + delta ), s.HeadWorld( 2 ).ToString() );
		Check( "tip's tail carried too", Near( s.TailWorld( 2 ), tipTailBefore + delta ), s.TailWorld( 2 ).ToString() );

		// Editing the MIDDLE of the chain must not disturb the root above it. It also does NOT drag
		// the tip's head along to chase mid's new tail — the tip's own Local is frozen relative to
		// mid's basis at whatever length mid had when the tip was placed, exactly the same "not
		// connected" rule the translation case above relies on, just now visible because THIS edit
		// also changes mid's length. What WorldBind guarantees is that the tip is recomputed fresh,
		// not stale — its world position has to move even though nothing about the tip itself was
		// touched, because it composes against mid's new Local.
		var rootHeadNow = s.HeadWorld( 0 );
		var tipHeadBeforeMidEdit = s.HeadWorld( 2 );
		var newMidTail = new Vec3( 1, 8, 2 );
		s.SetHeadTail( 1, s.HeadWorld( 1 ), newMidTail );

		Check( "editing mid leaves root alone", Near( s.HeadWorld( 0 ), rootHeadNow ), s.HeadWorld( 0 ).ToString() );
		Check( "mid's own head/tail land exactly where requested",
			Near( s.HeadWorld( 1 ), midHeadBefore + delta ) && Near( s.TailWorld( 1 ), newMidTail ),
			s.TailWorld( 1 ).ToString() );
		Check( "mid's length is measured from the new tail, not left stale",
			MathF.Abs( s.Bones[1].Length - (newMidTail - ( midHeadBefore + delta )).Length ) < 1e-3f );
		Check( "the tip is recomputed fresh rather than cached",
			(s.HeadWorld( 2 ) - tipHeadBeforeMidEdit).Length > 0.5f, s.HeadWorld( 2 ).ToString() );

		var threw = false;
		try { s.SetHeadTail( 0, Vec3.Zero, Vec3.Zero ); } catch ( ArgumentException ) { threw = true; }
		Check( "a zero-length edit is refused", threw );

		threw = false;
		try { s.SetHeadTail( 99, Vec3.Zero, new Vec3( 0, 1, 0 ) ); } catch ( ArgumentOutOfRangeException ) { threw = true; }
		Check( "editing an out-of-range index is refused", threw );
	}

	static void TestEuler()
	{
		// Euler order is the classic silent-wrongness bug: any single-axis rotation round-trips
		// under every convention, so only compound rotations can tell them apart.
		var rng = new Random( 12345 );
		var worst = 0f;

		for ( var i = 0; i < 200; i++ )
		{
			var axis = new Vec3(
				(float)rng.NextDouble() * 2 - 1,
				(float)rng.NextDouble() * 2 - 1,
				(float)rng.NextDouble() * 2 - 1 );

			if ( axis.LengthSquared < 1e-4f )
				continue;

			var r = Xform.Rotate( axis, (float)rng.NextDouble() * MathF.PI * 1.9f - MathF.PI * 0.95f );
			var back = Xform.FromEulerXyz( r.ToEulerXyz() );

			worst = MathF.Max( worst, (back.X - r.X).Length );
			worst = MathF.Max( worst, (back.Y - r.Y).Length );
			worst = MathF.Max( worst, (back.Z - r.Z).Length );
		}

		Check( "200 random rotations survive matrix→Euler→matrix", worst < 1e-3f, $"worst axis error {worst:0.######}" );

		// Straight up is the gimbal pole, where cos(pitch) is zero and the other two angles stop
		// being separable. The rebuilt matrix still has to match even though the angles will not.
		var pole = Xform.Rotate( new Vec3( 0, 0, 1 ), MathF.PI / 2f ) * Xform.Rotate( new Vec3( 0, 1, 0 ), -MathF.PI / 2f );
		var poleBack = Xform.FromEulerXyz( pole.ToEulerXyz() );
		Check( "the gimbal pole still round-trips",
			(poleBack.X - pole.X).Length < 1e-3f && (poleBack.Y - pole.Y).Length < 1e-3f,
			$"{(poleBack.X - pole.X).Length:0.####}" );

		var inv = Xform.Rotate( new Vec3( 1, 2, 3 ), 0.7f ) * Xform.Translate( new Vec3( 4, -1, 2 ) );
		var round = inv * inv.Inverse;
		Check( "Xform.Inverse is actually an inverse",
			Near( round.TransformPoint( new Vec3( 5, 6, 7 ) ), new Vec3( 5, 6, 7 ) ) );
	}

	// --- binding ------------------------------------------------------------------------

	static void TestBinding()
	{
		var mesh = Primitives.Cylinder( 0.5f, 4f, 12 );
		var skeleton = CylinderChain();

		var rigid = SkinBinder.BindRigid( mesh, skeleton );
		Check( "rigid: one set per vertex", rigid.Count == mesh.VertexCount );
		Check( "rigid: valid", rigid.Validate( mesh.VertexCount, skeleton.Count ).Count == 0,
			string.Join( "; ", rigid.Validate( mesh.VertexCount, skeleton.Count ).Take( 2 ) ) );
		Check( "rigid: single influence each", rigid.Vertices.All( w => w.Length == 1 ) );
		Check( "rigid: both bones used", rigid.Vertices.Any( w => w[0].Bone == 0 ) && rigid.Vertices.Any( w => w[0].Bone == 1 ) );

		var smooth = SkinBinder.BindSmooth( mesh, skeleton );
		Check( "smooth: valid", smooth.Validate( mesh.VertexCount, skeleton.Count ).Count == 0,
			string.Join( "; ", smooth.Validate( mesh.VertexCount, skeleton.Count ).Take( 2 ) ) );
		Check( "smooth: blends across the joint", smooth.Vertices.Any( w => w.Length > 1 ) );

		// A higher falloff exponent must concentrate weight, not spread it. If this inverts, the
		// slider in the UI would work backwards and nothing else would complain.
		float SpreadOf( float falloff )
		{
			var w = SkinBinder.BindSmooth( mesh, skeleton, falloff );
			return (float)w.Vertices.Average( v => v.Length );
		}

		Check( "higher falloff means tighter binding", SpreadOf( 6f ) <= SpreadOf( 1.5f ),
			$"{SpreadOf( 6f ):0.##} vs {SpreadOf( 1.5f ):0.##}" );

		var smoothed = SkinBinder.SmoothWeights( mesh, rigid, 3 );
		Check( "smoothing keeps weights valid", smoothed.Validate( mesh.VertexCount, skeleton.Count ).Count == 0 );
		Check( "smoothing softens a rigid bind", smoothed.Vertices.Any( w => w.Length > 1 ) );

		var threw = false;
		try { SkinBinder.BindRigid( mesh, new Skeleton() ); } catch ( InvalidOperationException ) { threw = true; }
		Check( "binding to an empty skeleton is refused", threw );

		// Pruning is the one lossy step in the whole path, so it has to stay a partition of unity.
		var many = new[]
		{
			new BoneWeight( 0, 0.4f ), new BoneWeight( 1, 0.3f ), new BoneWeight( 2, 0.15f ),
			new BoneWeight( 3, 0.1f ), new BoneWeight( 4, 0.05f )
		};
		var pruned = SkinWeights.Prune( many, 4 );
		Check( "prune drops to the cap", pruned.Length == 4 );
		Check( "prune renormalises to 1", MathF.Abs( pruned.Sum( w => w.Weight ) - 1f ) < 1e-5f );
		Check( "prune keeps the strongest", pruned.Any( w => w.Bone == 0 ) && pruned.All( w => w.Bone != 4 ) );
	}

	// --- subdivision --------------------------------------------------------------------

	static void TestWeightsThroughSubdivision()
	{
		var mesh = Primitives.Cylinder( 0.5f, 4f, 12 );
		var skeleton = CylinderChain();
		mesh.Skin = SkinBinder.BindSmooth( mesh, skeleton );

		Check( "mesh reports as rigged", mesh.IsRigged );

		var level2 = CatmullClark.Subdivide( mesh, 2 );

		Check( "subdivided mesh is still rigged", level2.IsRigged );
		Check( "one weight set per new vertex", level2.Skin.Count == level2.VertexCount,
			$"{level2.Skin.Count} vs {level2.VertexCount}" );

		var errors = level2.Skin.Validate( level2.VertexCount, skeleton.Count );
		Check( "every weight still sums to 1 and is in range", errors.Count == 0,
			string.Join( "; ", errors.Take( 3 ) ) );

		// The affine-combination property is the reason the above holds without a renormalise step.
		// Assert it directly, so a future "optimisation" that breaks it fails here rather than
		// showing up as a model that deforms slightly wrong at level 4.
		var worst = level2.Skin.Vertices.Max( w => MathF.Abs( w.Sum( b => b.Weight ) - 1f ) );
		Check( "no renormalisation needed anywhere", worst < 1e-4f, $"worst drift {worst:0.#######}" );
		Check( "no negative weights appear", level2.Skin.Vertices.All( w => w.All( b => b.Weight >= 0f ) ) );

		// An unrigged mesh must stay unrigged rather than gaining empty weights.
		var plain = CatmullClark.Subdivide( Primitives.Box( 1, 1, 1 ), 1 );
		Check( "an unrigged mesh subdivides without gaining a rig", plain.Skin is null );

		// Level 4 is the working limit the handoff quotes, so check the invariant holds that far.
		var level4 = CatmullClark.Subdivide( mesh, 4 );
		var deepErrors = level4.Skin.Validate( level4.VertexCount, skeleton.Count );
		Check( $"still valid at level 4 ({level4.VertexCount} verts)", deepErrors.Count == 0,
			string.Join( "; ", deepErrors.Take( 2 ) ) );
	}

	// --- feature binding ----------------------------------------------------------------

	static void TestFeatureBinding()
	{
		// Two boxes, so there are two bodies to bind to two bones.
		var studio = new PartStudio();

		var lower = studio.Add( new PrimitiveFeature() );
		lower.SizeX.Value = 1f; lower.SizeY.Value = 1f; lower.SizeZ.Value = 2f;
		lower.Position.Value = new Vec3( 0, 0, 1 );

		var upper = studio.Add( new PrimitiveFeature() );
		upper.SizeX.Value = 1f; upper.SizeY.Value = 1f; upper.SizeZ.Value = 2f;
		upper.Position.Value = new Vec3( 0, 0, 3 );

		studio.Rebuild();

		var (mesh, ranges) = studio.ToMeshWithBodies();
		Check( "two body ranges", ranges.Count == 2 );
		Check( "ranges cover every vertex", ranges.Sum( r => r.Count ) == mesh.VertexCount );
		Check( "ranges do not overlap", ranges[0].Start + ranges[0].Count == ranges[1].Start );

		var skeleton = new Skeleton();
		skeleton.AddBoneFromPoints( "lower", -1, new Vec3( 0, 0, 0 ), new Vec3( 0, 0, 2 ) );
		skeleton.AddBoneFromPoints( "upper", 0, new Vec3( 0, 0, 2 ), new Vec3( 0, 0, 4 ) );

		var binding = new Dictionary<string, string>
		{
			[ranges[0].BodyId] = "lower",
			[ranges[1].BodyId] = "upper"
		};

		var weights = SkinBinder.BindBodies( mesh, ranges, binding, skeleton );
		Check( "body binding is valid", weights.Validate( mesh.VertexCount, skeleton.Count ).Count == 0 );

		bool AllBoundTo( BodyRange range, int bone, SkinWeights w ) =>
			Enumerable.Range( range.Start, range.Count )
				.All( i => w[i].Length == 1 && w[i][0].Bone == bone );

		Check( "first body is wholly on its bone", AllBoundTo( ranges[0], 0, weights ) );
		Check( "second body is wholly on its bone", AllBoundTo( ranges[1], 1, weights ) );

		// THE POINT OF THE WHOLE APPROACH: change a parameter, rebuild, and re-apply the SAME
		// binding. Vertex indices have moved; body ids have not, so the rig still lands correctly.
		upper.SizeX.Value = 3f;
		studio.MarkDirty( upper );
		studio.Rebuild();

		var (mesh2, ranges2) = studio.ToMeshWithBodies();
		var weights2 = SkinBinder.BindBodies( mesh2, ranges2, binding, skeleton );

		Check( "rig still valid after a parameter change",
			weights2.Validate( mesh2.VertexCount, skeleton.Count ).Count == 0 );
		Check( "body ids are stable across the rebuild",
			ranges2[0].BodyId == ranges[0].BodyId && ranges2[1].BodyId == ranges[1].BodyId );
		Check( "the widened body is still wholly on its own bone", AllBoundTo( ranges2[1], 1, weights2 ) );

		// A binding naming a bone that does not exist has to be loud, not silently ignored.
		var threw = false;
		try
		{
			SkinBinder.BindBodies( mesh2, ranges2,
				new Dictionary<string, string> { [ranges2[0].BodyId] = "ghost" }, skeleton );
		}
		catch ( InvalidOperationException ) { threw = true; }
		Check( "binding to a missing bone is refused", threw );
	}

	// --- SMD ----------------------------------------------------------------------------

	static void TestSmd()
	{
		var mesh = Primitives.Box( 2, 2, 2 );
		var staticSmd = SmdWriter.Write( mesh );

		Check( "static export needs no skeleton", staticSmd.Contains( "nodes" ) && staticSmd.Contains( "\"root\"" ) );

		var readBack = SmdReader.Read( staticSmd );
		Check( "static: one root bone", readBack.Skeleton.Count == 1 && readBack.Skeleton.Bones[0].Parent == -1 );
		Check( "static: box is 12 triangles", readBack.TriangleCount == 12, $"got {readBack.TriangleCount}" );
		Check( "static: 3 corners per triangle", readBack.Corners.Count == readBack.TriangleCount * 3 );
		Check( "static: everything weighted to the root",
			readBack.Corners.All( c => c.Weights.Length == 1 && c.Weights[0].Bone == 0 && MathF.Abs( c.Weights[0].Weight - 1f ) < 1e-4f ) );

		// A cylinder rigged and subdivided is the real case: many bones per vertex, many vertices.
		var rigged = Primitives.Cylinder( 0.5f, 4f, 12 );
		var skeleton = CylinderChain();
		rigged.Skin = SkinBinder.BindSmooth( rigged, skeleton );
		var dense = CatmullClark.Subdivide( rigged, 2 );

		var smd = SmdWriter.Write( dense, skeleton );
		var back = SmdReader.Read( smd );

		Check( "skinned: bone hierarchy survives", back.Skeleton.Count == 2 && back.Skeleton.Bones[1].Parent == 0 );
		Check( "skinned: bone names survive",
			back.Skeleton.Bones[0].Name == "root" && back.Skeleton.Bones[1].Name == "upper" );

		// The bind pose is written as local position + Euler rotation, so this is the test that
		// would catch a wrong Euler convention in a way a single-axis rig never could.
		Check( "skinned: bind pose round-trips",
			Near( back.Skeleton.HeadWorld( 1 ), skeleton.HeadWorld( 1 ), 1e-3f ),
			$"{back.Skeleton.HeadWorld( 1 )} vs {skeleton.HeadWorld( 1 )}" );

		Check( "skinned: influences are capped at 4",
			back.Corners.All( c => c.Weights.Length is > 0 and <= SmdWriter.MaxInfluences ) );
		Check( "skinned: every vertex's weights sum to 1",
			back.Corners.All( c => MathF.Abs( c.Weights.Sum( w => w.Weight ) - 1f ) < 1e-3f ) );
		Check( "skinned: some vertices genuinely blend two bones",
			back.Corners.Any( c => c.Weights.Length > 1 ) );
		Check( "skinned: triangle count matches the quads",
			back.TriangleCount == dense.Faces.Sum( f => f.Count - 2 ), $"got {back.TriangleCount}" );

		// Material slots have to survive as separate groups, or a two-material prop imports as one.
		var twoTone = Primitives.Box( 1, 1, 1 );
		twoTone.Faces[0].Material = 3;
		var matSmd = SmdReader.Read( SmdWriter.Write( twoTone ) );
		Check( "material slots are written per triangle",
			matSmd.Materials.Distinct().Count() == 2, string.Join( ",", matSmd.Materials.Distinct() ) );

		var threw = false;
		try
		{
			var bad = Primitives.Box( 1, 1, 1 );
			bad.Skin = SkinWeights.AllTo( bad.VertexCount, 9 );
			SmdWriter.Write( bad, Skeleton.SingleRoot() );
		}
		catch ( InvalidOperationException ) { threw = true; }
		Check( "a weight pointing off the end of the skeleton is refused", threw );
	}

	/// <summary>Bugs found by the second review cycle, kept together so they cannot come back.</summary>
	static void TestReviewRegressions()
	{
		// Prune returned short weight sets in whatever order they arrived, but SmdWriter takes
		// weights[0] as the vertex's parent bone on the documented understanding that it is the
		// strongest. A vertex weighted 0.2/0.8 named the wrong bone as its parent.
		var unsorted = new[] { new BoneWeight( 0, 0.2f ), new BoneWeight( 1, 0.8f ) };
		var pruned = SkinWeights.Prune( unsorted, 4 );

		Check( "prune sorts even when it drops nothing", pruned[0].Bone == 1,
			$"parent came out as bone {pruned[0].Bone}" );
		Check( "and returns a copy, not the caller's array", !ReferenceEquals( pruned, unsorted ) );

		// An unrigged body merged into a rigged one used to leave empty influences: IsRigged stayed
		// true, Validate failed on every one of them, and SMD read "no links" as the parent column.
		var skeleton = TwoBoneChain();
		var rigged = Primitives.Box( 1, 1, 1 );
		rigged.Skin = SkinBinder.BindRigid( rigged, skeleton );

		var plain = Primitives.Box( 1, 1, 1 );
		MeshTransform.Apply( plain, Xform.Translate( new Vec3( 3, 0, 0 ) ) );
		MeshTransform.Append( rigged, plain );

		Check( "merging unrigged geometry into a rigged body leaves no unweighted vertices",
			rigged.Skin.Validate( rigged.VertexCount, skeleton.Count ).Count == 0,
			string.Join( "; ", rigged.Skin.Validate( rigged.VertexCount, skeleton.Count ).Take( 2 ) ) );

		// A reused feature's error vanished from the report, so an unrelated downstream edit made a
		// broken model look clean.
		var studio = new PartStudio();

		var broken = studio.Add( new PrimitiveFeature() );
		broken.Shape.Index = 4;              // Tube
		broken.InnerRadius.Value = 9f;       // larger than the radius, so it throws

		var after = studio.Add( new PrimitiveFeature() );

		Check( "a broken feature is reported on the first rebuild", studio.Rebuild().HasErrors );

		after.SizeX.Value = 2f;
		studio.MarkDirty( after );

		Check( "and still reported after an unrelated downstream edit", studio.Rebuild().HasErrors );
	}

	// --- helpers ------------------------------------------------------------------------

	static bool Near( Vec3 a, Vec3 b, float tolerance = 1e-4f ) => (a - b).Length < tolerance;

	/// <summary>
	/// The mesh the pose preview deforms: a cylinder bound to a two-bone chain down Z, the top half
	/// owned by the "upper" bone. Posing that bone must move the top and leave the bottom, which is
	/// the one distinction a broken bind-pose convention gets wrong silently.
	/// </summary>
	static void TestDeform()
	{
		var mesh = Primitives.Cylinder( 0.5f, 4f, 16 );
		var skeleton = CylinderChain( 4f );
		var weights = SkinBinder.BindRigid( mesh, skeleton );

		var bind = new Xform[skeleton.Count];
		for ( var i = 0; i < skeleton.Count; i++ )
			bind[i] = skeleton.WorldBind( i );

		var atBind = SkinBinder.Deform( mesh.Positions, weights, bind, bind );
		Check( "a zero pose leaves the mesh unchanged", Same( mesh.Positions, atBind ) );

		// Rotate the upper bone 90 degrees about +X. Its bind transform sits with its head at the
		// origin and its tail at +Z, so the swing is about the joint — the tail sweeps from +Z to -Y.
		var pose = (Xform[])bind.Clone();
		pose[1] = Xform.Rotate( new Vec3( 1, 0, 0 ), MathF.PI / 2f ) * bind[1];

		var posed = SkinBinder.Deform( mesh.Positions, weights, bind, pose );

		var top = 0;
		var bottom = 0;

		for ( var i = 0; i < mesh.Positions.Count; i++ )
		{
			if ( mesh.Positions[i].z > mesh.Positions[top].z ) top = i;
			if ( mesh.Positions[i].z < mesh.Positions[bottom].z ) bottom = i;
		}

		Check( "the top of the cylinder swings with its bone",
			Near( posed[top], Xform.Rotate( new Vec3( 1, 0, 0 ), MathF.PI / 2f ).TransformPoint( mesh.Positions[top] ), 1e-3f ),
			posed[top].ToString() );
		Check( "the bottom stays on its unposed bone",
			Near( posed[bottom], mesh.Positions[bottom], 1e-3f ), posed[bottom].ToString() );
	}

	static bool Same( IReadOnlyList<Vec3> a, IReadOnlyList<Vec3> b )
	{
		if ( a.Count != b.Count )
			return false;

		for ( var i = 0; i < a.Count; i++ )
		{
			if ( !Near( a[i], b[i] ) )
				return false;
		}

		return true;
	}

	static void Section( string title ) => Report.Section( title );

	static void Check( string what, bool ok, string detail = null ) => Report.Check( what, ok, detail );
}