Effigy.Tests/WeightPaintTests.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// Weight painting: the brush, the invariant it must not break, and the layer that makes paint
/// survive a rebuild.
///
/// THE INVARIANT IS THE THING UNDER TEST, not the brush. Every vertex's influences are non-negative
/// and sum to one, and everything downstream leans on it — `Prune` renormalises against it,
/// Catmull-Clark's affine combinations preserve it, the compiler's own culling assumes it. A brush
/// that breaks it produces a model that loads, renders, and deforms wrongly in a way nobody can
/// point at. So the sum is checked after every operation here, including the ones expected to fail.
///
/// Second thing under test, and the one a naive implementation gets wrong in the other direction: a
/// vertex weighted ENTIRELY to the bone being subtracted from has nowhere to put the weight, and
/// both tempting answers are bad. Normalising an all-zero set binds the vertex to nothing, which
/// collapses it to the model origin on export. Leaving 1.0 silently makes the brush look broken. It
/// is refused and counted instead.
/// </summary>
public static class WeightPaintTests
{
	public static void Run()
	{
		Section( "weight paint: the partition of unity survives every brush" );
		TestSumStaysOne();

		Section( "weight paint: what the brush does, and to whom" );
		TestAddTakesFromTheOthers();
		TestSubtractGivesItBack();
		TestSetEasesRatherThanSnapping();
		TestSmoothPullsTowardTheNeighbours();
		TestFalloffMeansTheEdgeIsSofter();

		Section( "weight paint: the vertex that cannot move, and says so" );
		TestASingleInfluenceVertexIsRefusedNotMangled();

		Section( "weight paint: a stroke is one undo entry" );
		TestStrokeUndoRedo();
		TestUndoTakesTheLayerBackToo();

		Section( "weight paint: the ramp is a texture atlas, not vertex colour" );
		TestRampIsACanvasNotVertexColours();

		Section( "weight paint: paint survives a rebuild, and refuses one it cannot" );
		TestPaintSurvivesAMovedCage();
		TestPaintIsKeptRatherThanMisappliedOnATopologyChange();
		TestBonesAreStoredByNameNotIndex();
		TestADeletedBoneIsDroppedAndNamed();
	}

	/// <summary>
	/// Every brush, over every vertex, and the one number that has to hold afterwards. Run against a
	/// mesh whose vertices carry several influences each, because a single-influence mesh cannot
	/// break the invariant however hard it is painted.
	/// </summary>
	static void TestSumStaysOne()
	{
		foreach ( var kind in new[]
		{
			WeightBrushKind.Add, WeightBrushKind.Subtract, WeightBrushKind.Set, WeightBrushKind.Smooth
		} )
		{
			var (mesh, weights, _) = Rigged();

			var stroke = new WeightStroke { Kind = kind, Bone = 1, Target = 0.6f, Mirror = MirrorAxis.X };

			// Several dabs, deliberately overlapping, because a brush that renormalises correctly
			// once can still drift when applied on top of its own output.
			for ( var i = 0; i < 12; i++ )
				stroke.Samples.Add( new WeightSample( new Vec3( -1f + i * 0.2f, 0, 0.5f ), 0.9f, 0.4f ) );

			WeightBrush.Apply( mesh, weights, stroke );

			var worst = 0f;
			var negative = 0;

			for ( var v = 0; v < mesh.VertexCount; v++ )
			{
				var sum = 0f;

				foreach ( var w in weights[v] )
				{
					sum += w.Weight;

					if ( w.Weight < 0f )
						negative++;
				}

				worst = MathF.Max( worst, MathF.Abs( sum - 1f ) );
			}

			Check( $"{kind}: every vertex still sums to 1", worst < 1e-4f, $"worst drift {worst:0.#######}" );
			Check( $"{kind}: and no influence went negative", negative == 0, $"{negative} negative" );
		}
	}

	static void TestAddTakesFromTheOthers()
	{
		// Two bones at a quarter each and a third at a half. Raising bone 0 must take from the other
		// two IN PROPORTION - a brush that took it all from the largest, or split it evenly, would
		// pass a sum check and change the shape of the blend.
		var before = new[]
		{
			new BoneWeight( 0, 0.25f ), new BoneWeight( 1, 0.25f ), new BoneWeight( 2, 0.5f )
		};

		var after = WeightBrush.Retarget( before, 0, 0.5f );

		Check( "the painted bone lands on its target", Near( Of( after, 0 ), 0.5f ), $"{Of( after, 0 ):0.####}" );
		Check( "and the others keep their ratio to each other",
			Near( Of( after, 2 ) / Of( after, 1 ), 2f ),
			$"{Of( after, 1 ):0.####} and {Of( after, 2 ):0.####}" );
		Check( "summing to what is left", Near( Of( after, 1 ) + Of( after, 2 ), 0.5f ) );

		// A bone the vertex does not have yet is introduced rather than ignored - the common case
		// when painting a bone onto a region the auto-binder never gave it.
		var fresh = WeightBrush.Retarget( new[] { new BoneWeight( 3, 1f ) }, 7, 0.4f );

		Check( "a bone not on the vertex yet is added", Near( Of( fresh, 7 ), 0.4f ), $"{Of( fresh, 7 ):0.####}" );
		Check( "and the one that was there makes room", Near( Of( fresh, 3 ), 0.6f ), $"{Of( fresh, 3 ):0.####}" );
	}

	static void TestSubtractGivesItBack()
	{
		var before = new[] { new BoneWeight( 0, 0.8f ), new BoneWeight( 1, 0.1f ), new BoneWeight( 2, 0.1f ) };
		var after = WeightBrush.Retarget( before, 0, 0.4f );

		Check( "the painted bone comes down", Near( Of( after, 0 ), 0.4f ), $"{Of( after, 0 ):0.####}" );
		Check( "and the others share what it gave up, in proportion",
			Near( Of( after, 1 ), 0.3f ) && Near( Of( after, 2 ), 0.3f ),
			$"{Of( after, 1 ):0.####} and {Of( after, 2 ):0.####}" );

		// TAKEN TO ZERO, THE BONE LEAVES. A zero influence is an export slot spent on nothing, and
		// Prune would rather have the room - four is the whole budget.
		var gone = WeightBrush.Retarget( before, 0, 0f );

		Check( "a bone taken to zero is removed rather than left at 0",
			gone.All( w => w.Bone != 0 ), string.Join( ", ", gone.Select( w => w.ToString() ) ) );
		Check( "and what is left still sums to 1", Near( gone.Sum( w => w.Weight ), 1f ) );
	}

	static void TestSetEasesRatherThanSnapping()
	{
		var (mesh, weights, _) = Rigged();

		// One dab, dead centre of the mesh, at a strength well below 1. Set that SNAPPED would put
		// every vertex in the disc on the target regardless of how far out it was, which is a
		// hard-edged patch of weight that no amount of falloff softens.
		var stroke = new WeightStroke { Kind = WeightBrushKind.Set, Bone = 1, Target = 1f };
		stroke.Samples.Add( new WeightSample( Vec3.Zero, 4f, 0.5f ) );

		WeightBrush.Apply( mesh, weights, stroke );

		var values = new List<(float Distance, float Weight)>();

		for ( var v = 0; v < mesh.VertexCount; v++ )
			values.Add( ((mesh.Positions[v] - Vec3.Zero).Length, Of( weights[v], 1 )) );

		var near = values.OrderBy( t => t.Distance ).First();
		var far = values.OrderByDescending( t => t.Distance ).First();

		Check( "Set moves the nearest vertex further than the furthest one",
			near.Weight > far.Weight + 1e-3f, $"{near.Weight:0.###} near, {far.Weight:0.###} far" );
		Check( "and does not reach the target in one dab at half strength",
			near.Weight < 1f - 1e-3f, $"{near.Weight:0.####}" );
	}

	static void TestSmoothPullsTowardTheNeighbours()
	{
		var (mesh, weights, _) = Rigged();

		// A deliberate discontinuity: one vertex given entirely to a bone none of its neighbours
		// carry. That is exactly what an auto-bind mistake looks like on a model, and what smoothing
		// is for.
		var spike = 0;
		weights[spike] = new[] { new BoneWeight( 2, 1f ) };

		var neighbourCarry = Neighbours( mesh, spike ).Average( n => Of( weights[n], 2 ) );

		var stroke = new WeightStroke { Kind = WeightBrushKind.Smooth };
		stroke.Samples.Add( new WeightSample( mesh.Positions[spike], 1.5f, 1f ) );

		WeightBrush.Apply( mesh, weights, stroke );

		var now = Of( weights[spike], 2 );

		Check( "smoothing pulls a spike down toward its neighbours",
			now < 1f - 1e-3f && now > neighbourCarry - 1e-3f,
			$"{now:0.####}, neighbours average {neighbourCarry:0.####}" );
		Check( "and the vertex still sums to 1", Near( weights[spike].Sum( w => w.Weight ), 1f ) );
	}

	static void TestFalloffMeansTheEdgeIsSofter()
	{
		// MEASURED AS THE CHANGE, NOT THE VALUE, and that distinction is the test getting itself
		// right rather than a nicety: Add targets `current + amount`, so two vertices given the same
		// amount still end on different values when they started on different ones. Comparing final
		// weights would call a correct constant falloff uneven.
		//
		// Constant falloff is the control - with it, distance inside the brush stops mattering. Any
		// other kind must move the near vertex further than the far one, or the falloff is not
		// reaching the weight maths at all.
		float Spread( BrushFalloff falloff )
		{
			var (mesh, weights, _) = Rigged();
			var before = Snapshot( weights );

			var stroke = new WeightStroke { Kind = WeightBrushKind.Add, Bone = 1, Falloff = falloff };
			stroke.Samples.Add( new WeightSample( Vec3.Zero, 4f, 0.4f ) );

			WeightBrush.Apply( mesh, weights, stroke );

			var ordered = Enumerable.Range( 0, mesh.VertexCount )
				.OrderBy( v => (mesh.Positions[v] - Vec3.Zero).Length )
				.ToList();

			var near = Of( weights[ordered[0]], 1 ) - Of( before[ordered[0]], 1 );
			var far = Of( weights[ordered[^1]], 1 ) - Of( before[ordered[^1]], 1 );

			return near - far;
		}

		Check( "a smooth falloff moves the near vertex more than the far one", Spread( BrushFalloff.Smooth ) > 1e-3f,
			$"{Spread( BrushFalloff.Smooth ):0.####}" );
		Check( "and a constant one moves them by the same amount", MathF.Abs( Spread( BrushFalloff.Constant ) ) < 1e-4f,
			$"{Spread( BrushFalloff.Constant ):0.#######}" );
	}

	/// <summary>
	/// The case with no answer. See this file's own comment for why both alternatives to refusing
	/// are worse than refusing.
	/// </summary>
	static void TestASingleInfluenceVertexIsRefusedNotMangled()
	{
		var only = new[] { new BoneWeight( 4, 1f ) };

		Check( "a vertex with one influence cannot have it lowered",
			WeightBrush.Retarget( only, 4, 0.5f ) is null );
		Check( "nor taken to zero", WeightBrush.Retarget( only, 4, 0f ) is null );
		Check( "but painting a DIFFERENT bone onto it works, which is the way out",
			WeightBrush.Retarget( only, 9, 0.5f ) is { Length: 2 } );

		// End to end: a rigidly bound mesh is entirely single-influence, so subtracting from it must
		// change nothing at all and must SAY how much it could not change.
		var mesh = Primitives.Box( 2, 2, 2 );
		var weights = SkinWeights.AllTo( mesh.VertexCount, 0 );

		var stroke = new WeightStroke { Kind = WeightBrushKind.Subtract, Bone = 0 };
		stroke.Samples.Add( new WeightSample( Vec3.Zero, 10f, 1f ) );

		var undo = WeightBrush.Apply( mesh, weights, stroke );

		Check( "a rigidly bound mesh is left completely alone", undo.Count == 0, $"{undo.Count} changed" );
		Check( "and every vertex still weighs 1 to its bone",
			Enumerable.Range( 0, mesh.VertexCount ).All( v => Near( Of( weights[v], 0 ), 1f ) ) );
		Check( "and the tool can say how many vertices it could not move",
			WeightBrush.CountLocked( mesh, weights, stroke ) == mesh.VertexCount,
			$"{WeightBrush.CountLocked( mesh, weights, stroke )} of {mesh.VertexCount}" );
	}

	static void TestStrokeUndoRedo()
	{
		var (mesh, weights, skeleton) = Rigged();
		var session = new WeightPaintSession( mesh, weights, skeleton ) { Bone = 1, Radius = 1.5f, Strength = 0.5f };

		var before = Snapshot( weights );

		// Down the +z axis at the top face, which the box's own vertices are under.
		Check( "the stroke lands on the model",
			session.BeginStroke( new Vec3( 0, 0, 10 ), new Vec3( 0, 0, -1 ) ) );

		session.MoveTo( new Vec3( 1.2f, 0, 10 ), new Vec3( 0, 0, -1 ) );

		var edit = session.EndStroke();

		Check( "and commits as one edit", edit is { Count: > 0 }, $"{edit?.Count ?? -1} vertices" );
		Check( "which changed the weights", !SameAs( weights, before ) );

		Check( "undo puts them back exactly", session.Undo() && SameAs( weights, before ) );
		Check( "redo puts them forward again", session.Redo() && !SameAs( weights, before ) );

		// A whole gesture is ONE entry - a user does not mean "one dab" by undo, and MoveTo above
		// produced several.
		Check( "and the whole gesture was one undo entry", session.Undo() && !session.CanUndo );
	}

	/// <summary>
	/// The failure that would be invisible for several actions and then blamed on the rebuild:
	/// undoing the weights while leaving the paint recorded.
	/// </summary>
	static void TestUndoTakesTheLayerBackToo()
	{
		var (mesh, weights, skeleton) = Rigged();
		var auto = Snapshot( weights );
		var session = new WeightPaintSession( mesh, weights, skeleton ) { Bone = 1, Radius = 1.5f, Strength = 0.6f };

		session.BeginStroke( new Vec3( 0, 0, 10 ), new Vec3( 0, 0, -1 ) );
		session.EndStroke();

		Check( "the stroke was recorded as paint", session.Layer.Count > 0, session.Layer.ToString() );

		session.Undo();

		// Re-applying the layer over the auto weights has to produce the auto weights, or the undone
		// stroke comes back the next time anything rebuilds.
		var rebound = Restore( auto );

		session.Layer.Apply( mesh, rebound, skeleton, out _ );

		Check( "and undoing it means a rebuild does not bring it back", SameAs( rebound, auto ),
			"the layer still carries the undone stroke" );
	}

	/// <summary>
	/// The whole point of the layer. A parametric edit moves the cage's positions and keeps its
	/// topology — the overwhelmingly common case — and the paint has to ride it.
	/// </summary>
	static void TestPaintSurvivesAMovedCage()
	{
		var (mesh, weights, skeleton) = Rigged();
		var session = new WeightPaintSession( mesh, weights, skeleton ) { Bone = 1, Radius = 1.5f, Strength = 0.7f };

		session.BeginStroke( new Vec3( 0, 0, 10 ), new Vec3( 0, 0, -1 ) );
		session.EndStroke();

		var painted = Snapshot( weights );

		// The rebuild: same topology, different positions, and freshly auto-bound weights that know
		// nothing about the paint.
		var rebuilt = mesh.Clone();

		for ( var v = 0; v < rebuilt.VertexCount; v++ )
			rebuilt.Positions[v] = new Vec3( rebuilt.Positions[v].x, rebuilt.Positions[v].y, rebuilt.Positions[v].z * 1.2f );

		var auto = SkinBinder.BindSmooth( rebuilt, skeleton );

		Check( "the layer accepts a cage that only moved", session.Layer.CanApply( rebuilt, out var why ), why );

		var applied = session.Layer.Apply( rebuilt, auto, skeleton, out var missing );

		Check( "and puts every painted vertex back", applied == session.Layer.Count,
			$"{applied} of {session.Layer.Count}" );
		Check( "with nothing missing", missing.Count == 0, string.Join( ", ", missing ) );

		var matched = 0;

		foreach ( var (vertex, _) in session.Layer.Painted )
		{
			if ( SameWeights( auto[vertex], painted[vertex] ) )
				matched++;
		}

		Check( "and the weights are the painted ones, not the binder's", matched == session.Layer.Count,
			$"{matched} of {session.Layer.Count}" );
	}

	static void TestPaintIsKeptRatherThanMisappliedOnATopologyChange()
	{
		var (mesh, weights, skeleton) = Rigged();
		var session = new WeightPaintSession( mesh, weights, skeleton ) { Bone = 1, Radius = 1.5f, Strength = 0.7f };

		session.BeginStroke( new Vec3( 0, 0, 10 ), new Vec3( 0, 0, -1 ) );
		session.EndStroke();

		var count = session.Layer.Count;
		var subdivided = CatmullClark.Subdivide( mesh, 1 );
		var auto = SkinBinder.BindSmooth( subdivided, skeleton );
		var untouched = Snapshot( auto );

		Check( "a topology change is refused", !session.Layer.CanApply( subdivided, out var why ) );
		Check( "and the refusal names both models' numbers",
			why is not null && why.Contains( subdivided.VertexCount.ToString() ) && why.Contains( count.ToString() ),
			why );

		var applied = session.Layer.Apply( subdivided, auto, skeleton, out _ );

		Check( "nothing is applied", applied == 0, $"{applied}" );
		Check( "the auto weights are left exactly as the binder made them", SameAs( auto, untouched ) );

		// KEPT, NOT DISCARDED. The topology change may well be undone - a fillet radius pushed too
		// far and pulled back - and losing somebody's paint to that would be unforgivable.
		Check( "the paint is kept", session.Layer.Count == count, $"{session.Layer.Count} of {count}" );
		Check( "and marked stale so the tool can say so", session.Layer.IsStale );

		session.Layer.Rebase( mesh );

		Check( "and rebasing onto the original mesh makes it usable again",
			!session.Layer.IsStale && session.Layer.CanApply( mesh, out _ ) );
	}

	/// <summary>
	/// Bone indices are not stable — `Skeleton.RemoveBone` re-indexes everything after the hole — so
	/// a layer holding indices would silently move to a different bone. Names are what this project
	/// keys rigs by everywhere else.
	/// </summary>
	static void TestBonesAreStoredByNameNotIndex()
	{
		var (mesh, weights, skeleton) = Rigged();
		var session = new WeightPaintSession( mesh, weights, skeleton ) { Bone = 2, Radius = 1.5f, Strength = 0.8f };

		session.BeginStroke( new Vec3( 0, 0, 10 ), new Vec3( 0, 0, -1 ) );
		session.EndStroke();

		var vertex = session.Layer.Painted.First().Vertex;
		var paintedSet = session.Layer.Painted.First( p => p.Vertex == vertex ).Weights;
		var paintedOnTip = Of( weights[vertex], 2 );

		Check( "the stroke put weight on the bone it was pointed at", paintedOnTip > 1e-3f,
			$"{paintedOnTip:0.####}" );

		// DELETE A BONE ABOVE THE PAINTED ONE, which is the case indices cannot survive: removing
		// index 1 from a three-bone skeleton leaves two, so a layer holding index 2 would be out of
		// range and the paint would vanish entirely. Holding the name, it is found at its new index.
		var trimmed = skeleton.Clone();
		trimmed.RemoveBone( 1 );

		var auto = SkinBinder.BindSmooth( mesh, trimmed );

		session.Layer.Apply( mesh, auto, trimmed, out var missing );

		var now = trimmed.IndexOf( skeleton.Bones[2].Name );

		// The painted share, renormalised over the bones that are still there - which is what the
		// layer documents itself as doing when a bone the paint referred to is gone.
		var survived = paintedSet.Where( w => trimmed.IndexOf( w.Bone ) >= 0 ).Sum( w => w.Weight );
		var expected = paintedOnTip / survived;

		Check( "after a bone above it is deleted, the paint follows the NAME to its new index",
			now >= 0 && Near( Of( auto[vertex], now ), expected ),
			$"'{skeleton.Bones[2].Name}' is now index {now}, carrying {Of( auto[vertex], now ):0.####}, expected {expected:0.####}" );
		Check( "and the deleted bone is reported rather than silently dropped",
			missing.Contains( skeleton.Bones[1].Name ), string.Join( ", ", missing ) );

		// A rename is an ordinary edit elsewhere in this project, so paint follows it too.
		var renamed = skeleton.Clone();
		renamed.RenameBone( 2, "tip_renamed" );

		Check( "and a rename can be followed", session.Layer.RenameBone( skeleton.Bones[2].Name, "tip_renamed" ) > 0 );

		var afterRename = SkinBinder.BindSmooth( mesh, renamed );
		session.Layer.Apply( mesh, afterRename, renamed, out var stillMissing );

		Check( "leaving nothing missing", stillMissing.Count == 0, string.Join( ", ", stillMissing ) );
	}

	static void TestADeletedBoneIsDroppedAndNamed()
	{
		var (mesh, weights, skeleton) = Rigged();
		var session = new WeightPaintSession( mesh, weights, skeleton ) { Bone = 2, Radius = 4f, Strength = 0.5f };

		session.BeginStroke( new Vec3( 0, 0, 10 ), new Vec3( 0, 0, -1 ) );
		session.EndStroke();

		var trimmed = skeleton.Clone();
		trimmed.RemoveBone( 2 );

		var auto = SkinBinder.BindSmooth( mesh, trimmed );

		session.Layer.Apply( mesh, auto, trimmed, out var missing );

		Check( "a bone the paint referred to and that is gone is named",
			missing.Count == 1 && missing[0] == skeleton.Bones[2].Name, string.Join( ", ", missing ) );

		// The remaining influences are renormalised rather than left summing to less than one, which
		// is the only defensible answer - the alternative is a partially-bound vertex.
		var worst = 0f;

		for ( var v = 0; v < mesh.VertexCount; v++ )
			worst = MathF.Max( worst, MathF.Abs( auto[v].Sum( w => w.Weight ) - 1f ) );

		Check( "and every vertex still sums to 1 afterwards", worst < 1e-4f, $"worst drift {worst:0.#######}" );
	}

	static void TestRampIsACanvasNotVertexColours()
	{
		var (mesh, weights, skeleton) = Rigged();
		var session = new WeightPaintSession( mesh, weights, skeleton ) { Bone = 2 };

		var canvas = session.Ramp( 64 );
		var painted = 0;
		var anyRed = false;

		for ( var i = 0; i < canvas.Rgba.Length; i += 4 )
		{
			if ( canvas.Rgba[i + 3] == 0 )
				continue;

			painted++;

			if ( canvas.Rgba[i] > canvas.Rgba[i + 2] )
				anyRed = true;
		}

		Check( "the ramp is a texture atlas", canvas.Width == 64 && painted > 0, $"{painted} texels" );
		Check( "and it never writes vertex colours — paint already taught that lesson",
			!mesh.HasVertexColors );
		Check( "a bone that owns some of the mesh shows up as warm texels, not as eight coloured corners",
			anyRed, "ramp was all cold" );
	}

	// --- fixtures ---------------------------------------------------------------------------------

	/// <summary>
	/// A subdivided box with a three-bone chain up its z axis, smooth-bound. Subdivided on purpose:
	/// smooth binding on a plain box gives nearly every vertex one influence, and a mesh that cannot
	/// break the invariant cannot test it either.
	/// </summary>
	static (PolyMesh Mesh, SkinWeights Weights, Skeleton Skeleton) Rigged()
	{
		var mesh = CatmullClark.Subdivide( Primitives.Box( 2, 2, 4 ), 2 );

		var skeleton = new Skeleton();
		var root = skeleton.AddBoneFromPoints( "root", -1, new Vec3( 0, 0, -2 ), new Vec3( 0, 0, -0.5f ) );
		var mid = skeleton.AddBoneFromPoints( "mid", root, new Vec3( 0, 0, -0.5f ), new Vec3( 0, 0, 1f ) );
		skeleton.AddBoneFromPoints( "tip", mid, new Vec3( 0, 0, 1f ), new Vec3( 0, 0, 2f ) );

		var weights = SkinBinder.BindSmooth( mesh, skeleton );

		return (mesh, weights, skeleton);
	}

	static IEnumerable<int> Neighbours( PolyMesh mesh, int vertex )
	{
		foreach ( var e in mesh.BuildVertexEdges()[vertex] )
			yield return e.A == vertex ? e.B : e.A;
	}

	static float Of( BoneWeight[] weights, int bone )
	{
		if ( weights is null )
			return 0f;

		foreach ( var w in weights )
		{
			if ( w.Bone == bone )
				return w.Weight;
		}

		return 0f;
	}

	static bool Near( float a, float b ) => MathF.Abs( a - b ) < 1e-4f;

	static BoneWeight[][] Snapshot( SkinWeights weights )
	{
		var copy = new BoneWeight[weights.Count][];

		for ( var i = 0; i < weights.Count; i++ )
			copy[i] = (BoneWeight[])weights[i].Clone();

		return copy;
	}

	static SkinWeights Restore( BoneWeight[][] snapshot )
	{
		var weights = new SkinWeights();

		foreach ( var w in snapshot )
			weights.Vertices.Add( (BoneWeight[])w.Clone() );

		return weights;
	}

	static bool SameAs( SkinWeights weights, BoneWeight[][] snapshot )
	{
		if ( weights.Count != snapshot.Length )
			return false;

		for ( var i = 0; i < snapshot.Length; i++ )
		{
			if ( !SameWeights( weights[i], snapshot[i] ) )
				return false;
		}

		return true;
	}

	static bool SameWeights( BoneWeight[] a, BoneWeight[] b )
	{
		if ( a.Length != b.Length )
			return false;

		for ( var i = 0; i < a.Length; i++ )
		{
			if ( a[i].Bone != b[i].Bone || MathF.Abs( a[i].Weight - b[i].Weight ) > 1e-5f )
				return false;
		}

		return true;
	}
}