Editor/Effigy/Rig/WeightBrush.cs
using System;
using System.Collections.Generic;

namespace Effigy;

/// <summary>What a weight-paint stroke does to the bone being painted.</summary>
public enum WeightBrushKind
{
	/// <summary>Raise the painted bone's influence, taking what it gains from the others.</summary>
	Add,

	/// <summary>Lower it, giving what it loses back to the others.</summary>
	Subtract,

	/// <summary>Move it toward an absolute value rather than by an amount.</summary>
	Set,

	/// <summary>Move every influence toward the average of the vertex's neighbours. Painted bone
	/// independent — it smooths the whole set, which is what makes a seam disappear.</summary>
	Smooth,
}

/// <summary>One sample on a weight stroke. The editor makes these; the kernel never learns what a
/// mouse is — same split as <see cref="BrushSample"/>, and deliberately the same shape.</summary>
public readonly struct WeightSample
{
	public readonly Vec3 Position;
	public readonly float Radius;
	public readonly float Strength;

	public WeightSample( Vec3 position, float radius, float strength )
	{
		Position = position;
		Radius = radius;
		Strength = strength;
	}
}

public sealed class WeightStroke
{
	public WeightBrushKind Kind;
	public BrushFalloff Falloff = BrushFalloff.Smooth;

	/// <summary>Which bone is being painted, by INDEX into the skeleton this stroke is running
	/// against. Ignored by Smooth, which has no one bone.</summary>
	public int Bone;

	/// <summary>Where Set is heading. Ignored by everything else.</summary>
	public float Target = 1f;

	public bool MirrorX;

	public readonly List<WeightSample> Samples = new();
}

/// <summary>
/// Per-stroke undo: the previous influences of every vertex the stroke actually changed.
///
/// Same shape and the same reasoning as <see cref="BrushUndo"/> — a naive undo snapshots every
/// vertex's weights, and a stroke touches a handful.
/// </summary>
public sealed class WeightUndo
{
	readonly Dictionary<int, BoneWeight[]> _previous = new();

	public int Count => _previous.Count;

	public IReadOnlyDictionary<int, BoneWeight[]> Previous => _previous;

	public void Record( int vertex, BoneWeight[] weights )
	{
		// FIRST WRITE WINS. A stroke crosses the same vertex several times as the cursor moves, and
		// the state to go back to is the one before the stroke began, not before its last sample.
		if ( !_previous.ContainsKey( vertex ) )
			_previous[vertex] = weights;
	}

	public void Absorb( WeightUndo later )
	{
		foreach ( var (vertex, weights) in later._previous )
			Record( vertex, weights );
	}

	public void Restore( SkinWeights weights )
	{
		foreach ( var (vertex, previous) in _previous )
		{
			if ( vertex >= 0 && vertex < weights.Count )
				weights[vertex] = previous;
		}
	}
}

/// <summary>
/// Painting skin weights by hand, to fix what auto-weighting gets wrong.
///
/// WHY IT EXISTS. `SkinBinder` is nearest-bone weighting smoothed across adjacency — a poor man's
/// heat diffusion, and it is right most of the time and wrong in exactly the places that show: a
/// finger that picks up the neighbouring finger's bone because the two are closer through space than
/// along the surface, an armpit, the inside of an elbow. Those are minutes of painting and hours of
/// anything else.
///
/// THE INVARIANT IS THE WHOLE PROBLEM. Every vertex's influences are non-negative and sum to one,
/// and everything downstream leans on that — `Prune` renormalises against it, Catmull-Clark's affine
/// combinations preserve it, and the compiler's own culling assumes it. So a brush cannot simply add
/// to one bone: whatever the painted bone gains has to come from the others, proportionally, and
/// whatever it loses has to go back to them the same way. Every operation here is written as "move
/// the painted bone to w, and rescale the rest to 1 - w".
///
/// THE CASE THAT HAS NO ANSWER, and the one to know about: a vertex weighted ENTIRELY to the bone
/// you are subtracting from has nowhere to put the weight. Rescaling an empty remainder is a divide
/// by zero, and the tempting fixes are both wrong — normalising an all-zero set gives a vertex bound
/// to nothing, which collapses to the model origin on export, and quietly leaving 1.0 in place makes
/// the brush look broken. So such a vertex is left alone and reported: `WeightUndo` never sees it,
/// and `WeightPaintSession` counts them so the tool can say "these vertices have only one bone;
/// paint the bone you want them to move to instead".
///
/// INFLUENCE COUNT IS NOT CAPPED HERE, deliberately, for the reason SkinWeights already gives:
/// authoring stays lossless and `Prune` runs once, at export. Painting can and will produce a vertex
/// with six influences, and that is fine.
/// </summary>
public static class WeightBrush
{
	/// <summary>
	/// Apply a stroke, and hand back what it would take to undo it.
	///
	/// The mesh is read for positions and adjacency and is never written — it is the weights that
	/// change, which is the one structural difference from the sculpt brush.
	/// </summary>
	public static WeightUndo Apply( PolyMesh mesh, SkinWeights weights, WeightStroke stroke,
		float[] mask = null, MeshBVH bvh = null )
	{
		if ( mesh is null )
			throw new ArgumentNullException( nameof( mesh ) );

		if ( weights is null )
			throw new ArgumentNullException( nameof( weights ) );

		if ( stroke is null )
			throw new ArgumentNullException( nameof( stroke ) );

		if ( weights.Count != mesh.VertexCount )
			throw new ArgumentException( $"weights ({weights.Count}) and mesh ({mesh.VertexCount}) disagree" );

		if ( mask is not null && mask.Length != mesh.VertexCount )
			throw new ArgumentException( $"mask ({mask.Length}) and mesh ({mesh.VertexCount}) disagree" );

		if ( stroke.Kind != WeightBrushKind.Smooth && stroke.Bone < 0 )
			throw new ArgumentException( "a weight stroke that is not Smooth has to name a bone" );

		bvh ??= MeshBVH.Build( mesh );

		var neighbours = mesh.BuildVertexEdges();
		var found = new List<int>();
		var undo = new WeightUndo();

		foreach ( var sample in stroke.Samples )
		{
			ApplySample( mesh, weights, stroke, mask, bvh, neighbours, found, undo, sample );

			if ( !stroke.MirrorX )
				continue;

			ApplySample( mesh, weights, stroke, mask, bvh, neighbours, found, undo,
				new WeightSample(
					new Vec3( -sample.Position.x, sample.Position.y, sample.Position.z ),
					sample.Radius, sample.Strength ) );
		}

		return undo;
	}

	/// <summary>
	/// How many vertices under this stroke could not move because they have only one influence.
	///
	/// Asked separately rather than returned from Apply, because the tool wants it to warn BEFORE
	/// the user has painted a stroke that visibly does nothing.
	/// </summary>
	public static int CountLocked( PolyMesh mesh, SkinWeights weights, WeightStroke stroke, MeshBVH bvh = null )
	{
		if ( stroke.Kind is not (WeightBrushKind.Subtract or WeightBrushKind.Set) )
			return 0;

		bvh ??= MeshBVH.Build( mesh );

		var found = new List<int>();
		var locked = new HashSet<int>();

		foreach ( var sample in stroke.Samples )
		{
			bvh.VerticesInRadius( mesh, sample.Position, sample.Radius, found );

			foreach ( var vi in found )
			{
				if ( OnlyInfluence( weights[vi], stroke.Bone ) )
					locked.Add( vi );
			}
		}

		return locked.Count;
	}

	static void ApplySample(
		PolyMesh mesh, SkinWeights weights, WeightStroke stroke, float[] mask,
		MeshBVH bvh, List<EdgeKey>[] neighbours, List<int> found, WeightUndo undo, WeightSample sample )
	{
		if ( sample.Radius <= 0f )
			return;

		bvh.VerticesInRadius( mesh, sample.Position, sample.Radius, found );

		foreach ( var vi in found )
		{
			var distance = (mesh.Positions[vi] - sample.Position).Length;
			var t = distance / sample.Radius;
			var amount = Brush.Falloff( t, stroke.Falloff ) * sample.Strength * (mask is null ? 1f : mask[vi]);

			if ( MathF.Abs( amount ) < 1e-6f )
				continue;

			var before = weights[vi];
			var after = stroke.Kind == WeightBrushKind.Smooth
				? SmoothOne( weights, neighbours[vi], vi, Math.Clamp( amount, 0f, 1f ) )
				: Retarget( before, stroke.Bone, TargetFor( before, stroke, amount ) );

			if ( after is null || Same( before, after ) )
				continue;

			undo.Record( vi, before );
			weights[vi] = after;
		}
	}

	/// <summary>Where this sample wants the painted bone to end up on this vertex.</summary>
	static float TargetFor( BoneWeight[] weights, WeightStroke stroke, float amount )
	{
		var current = WeightOf( weights, stroke.Bone );

		return stroke.Kind switch
		{
			WeightBrushKind.Add => current + amount,
			WeightBrushKind.Subtract => current - amount,
			// Set eases toward the target rather than snapping, so the falloff still shapes the edge
			// of the brush. Snapping would give a hard-edged disc regardless of falloff.
			WeightBrushKind.Set => current + (stroke.Target - current) * Math.Clamp( amount, 0f, 1f ),
			_ => current,
		};
	}

	/// <summary>
	/// The vertex's influences with one bone moved to `target` and the rest rescaled to fill the
	/// remainder. Null when it cannot be done.
	///
	/// This is the one function the invariant lives in. Three cases it has to get right:
	///
	/// - the bone is not on the vertex yet and is being raised — it gets added, and the existing
	///   influences are scaled down to make room.
	/// - the bone is being taken to zero — it is REMOVED rather than left at 0, because a zero
	///   influence is an export slot spent on nothing and `Prune` would rather have the room.
	/// - the bone is the vertex's only influence and is being lowered — refused, because there is
	///   nothing to rescale up. See the class comment.
	/// </summary>
	public static BoneWeight[] Retarget( BoneWeight[] weights, int bone, float target )
	{
		target = Math.Clamp( target, 0f, 1f );

		weights ??= Array.Empty<BoneWeight>();

		var others = 0f;

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

		// Nothing else to give to or take from. Raising to a full 1 is still meaningful (it is
		// already 1), and anything less would leave the remainder nowhere to go.
		if ( others <= 1e-6f && target < 1f - 1e-6f )
			return null;

		// A vertex with no influences at all is not something painting should be inventing weights
		// for - it means the mesh was never bound - so it is only ever taken straight to the bone.
		if ( weights.Length == 0 )
			return target > 1e-6f ? new[] { new BoneWeight( bone, 1f ) } : null;

		var result = new List<BoneWeight>( weights.Length + 1 );
		var scale = others > 1e-9f ? (1f - target) / others : 0f;

		if ( target > 1e-6f )
			result.Add( new BoneWeight( bone, target ) );

		foreach ( var w in weights )
		{
			if ( w.Bone == bone )
				continue;

			var scaled = w.Weight * scale;

			// Dropping what rounds to nothing keeps a painted vertex from accumulating a tail of
			// influences worth a millionth each, which cost an export slot and buy nothing.
			if ( scaled > 1e-5f )
				result.Add( new BoneWeight( w.Bone, scaled ) );
		}

		if ( result.Count == 0 )
			return null;

		// Through Blend, so normalisation and ordering are the same one definition the binders and
		// subdivision already go through rather than a second one written here.
		return SkinWeights.Blend( new[] { (result.ToArray(), 1f) } );
	}

	/// <summary>One vertex moved toward the average of its neighbours' influences.</summary>
	static BoneWeight[] SmoothOne( SkinWeights weights, List<EdgeKey> edges, int vertex, float amount )
	{
		if ( edges.Count == 0 || amount <= 0f )
			return weights[vertex];

		var terms = new List<(BoneWeight[], float)>( edges.Count + 1 )
		{
			(weights[vertex], 1f - amount)
		};

		var share = amount / edges.Count;

		foreach ( var e in edges )
			terms.Add( (weights[e.A == vertex ? e.B : e.A], share) );

		return SkinWeights.Blend( terms );
	}

	static float WeightOf( 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 OnlyInfluence( BoneWeight[] weights, int bone ) =>
		weights is { Length: 1 } && weights[0].Bone == bone;

	static bool Same( BoneWeight[] a, BoneWeight[] b )
	{
		if ( a is null || b is null || 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-6f )
				return false;
		}

		return true;
	}
}