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

namespace Effigy;

/// <summary>One committed weight stroke, held so it can be undone and redone.</summary>
public sealed class WeightEdit
{
	readonly Dictionary<int, BoneWeight[]> _before;
	readonly Dictionary<int, BoneWeight[]> _after;

	public WeightEdit( Dictionary<int, BoneWeight[]> before, Dictionary<int, BoneWeight[]> after )
	{
		_before = before;
		_after = after;
	}

	/// <summary>How many vertices this stroke actually changed.</summary>
	public int Count => _before.Count;

	internal void Apply( SkinWeights weights, bool forward )
	{
		var source = forward ? _after : _before;

		foreach ( var (vertex, value) in source )
		{
			if ( vertex >= 0 && vertex < weights.Count )
				weights[vertex] = value;
		}
	}

	internal IEnumerable<int> Vertices => _before.Keys;
}

/// <summary>
/// Everything about weight painting that is arithmetic rather than widgets.
///
/// SAME SPLIT AS SculptSession, AND FOR THE SAME REASON: the half that can be tested without s&box
/// is worth having as its own object, and it turns out to be nearly all of it. The editor's job
/// reduces to turning a mouse into a ray and drawing a ring.
///
/// A STROKE IS ONE UNDO ENTRY. The brush is applied live so the viewport can colour the weights as
/// the cursor moves, and the whole gesture is committed once at <see cref="EndStroke"/> — because
/// one dab is not what a user means by "undo".
///
/// **The layer is written at the same moment.** A stroke that changed the weights and did not reach
/// <see cref="Layer"/> would look correct until the next rebuild and then vanish, which is the
/// single worst failure this tool can have: it is invisible at the moment it happens and blamed on
/// something else later. So committing a stroke and recording it as paint are one operation, and
/// undo takes both back.
/// </summary>
public sealed class WeightPaintSession
{
	readonly PolyMesh _mesh;
	readonly SkinWeights _weights;
	readonly Skeleton _skeleton;
	readonly MeshBVH _bvh;

	readonly Stack<WeightEdit> _done = new();
	readonly Stack<WeightEdit> _undone = new();

	// Live only between BeginStroke and EndStroke.
	WeightUndo _undo;
	Vec3 _lastSample;
	bool _stroking;

	public WeightPaintSession( PolyMesh mesh, SkinWeights weights, Skeleton skeleton )
	{
		_mesh = mesh ?? throw new ArgumentNullException( nameof( mesh ) );
		_weights = weights ?? throw new ArgumentNullException( nameof( weights ) );
		_skeleton = skeleton ?? throw new ArgumentNullException( nameof( skeleton ) );

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

		// Built once. The mesh does not move while weights are painted, which is the one thing that
		// makes this cheaper than the sculpt session - there is no working copy and no refit.
		_bvh = MeshBVH.Build( mesh );
		Layer = new WeightPaintLayer( mesh );
	}

	public PolyMesh Mesh => _mesh;
	public SkinWeights Weights => _weights;
	public Skeleton Skeleton => _skeleton;

	/// <summary>What was painted, in the form that survives a rebuild. See WeightPaintLayer.</summary>
	public WeightPaintLayer Layer { get; private set; }

	public WeightBrushKind Brush = WeightBrushKind.Add;
	public BrushFalloff Falloff = BrushFalloff.Smooth;

	/// <summary>Which bone the brush paints. -1 is nothing, and every kind but Smooth refuses.</summary>
	public int Bone = -1;

	/// <summary>Brush radius in world units, not pixels — the kernel has no screen.</summary>
	public float Radius = 0.25f;

	/// <summary>
	/// A starting radius that means something on THIS model. Effigy's units are dimensionless, so a
	/// constant is a brush that covers a whole prop and a pixel of a room - same reasoning as
	/// SculptSession's.
	/// </summary>
	public float SuggestedRadius => MathF.Max( _mesh.BoundsDiagonal * 0.08f, 1e-4f );

	/// <summary>
	/// How much one dab moves the weight. Low on purpose: weight painting is built out of many light
	/// passes, and a brush that takes a vertex to 1.0 in one dab cannot be used to blend anything.
	/// </summary>
	public float Strength = 0.15f;

	/// <summary>Where Set is heading, when the brush is Set.</summary>
	public float Target = 1f;

	public bool MirrorX;

	/// <summary>Fraction of the radius the cursor must travel to earn another sample.</summary>
	public float Spacing = 0.25f;

	/// <summary>Most samples one pointer move may become, so a flick across the model cannot stall
	/// the tool.</summary>
	public int MaxSamplesPerMove = 64;

	public bool IsStroking => _stroking;
	public bool CanUndo => _done.Count > 0;
	public bool CanRedo => _undone.Count > 0;

	/// <summary>
	/// How much of the painted bone each vertex carries, for a viewport that colours the model by
	/// weight. Nothing else in the kernel can produce this, and every tool that paints weights shows
	/// it — painting blind is not painting.
	/// </summary>
	public float[] Influence( int bone )
	{
		var values = new float[_mesh.VertexCount];

		if ( bone < 0 )
			return values;

		for ( var v = 0; v < _mesh.VertexCount; v++ )
		{
			foreach ( var w in _weights[v] )
			{
				if ( w.Bone != bone )
					continue;

				values[v] = w.Weight;
				break;
			}
		}

		return values;
	}

	/// <summary>
	/// Start a stroke at whatever the ray hits. False when it misses, which is an ordinary outcome
	/// and not an error.
	/// </summary>
	public bool BeginStroke( Vec3 origin, Vec3 direction )
	{
		if ( _stroking )
			throw new InvalidOperationException( "A stroke is already running; end it before starting another." );

		if ( Radius <= 0f )
			throw new InvalidOperationException( $"A brush needs a radius; this one is {Radius}." );

		if ( Brush != WeightBrushKind.Smooth && Bone < 0 )
			throw new InvalidOperationException( "Pick a bone to paint before painting." );

		var hit = _bvh.Raycast( _mesh, origin, direction.Normal );

		if ( hit is null )
			return false;

		_stroking = true;
		_undo = new WeightUndo();
		_lastSample = hit.Value.Point;

		Dab( hit.Value.Point );
		return true;
	}

	/// <summary>
	/// Carry the stroke to a new pointer position, filling in the gap the pointer skipped. Returns
	/// how many samples that took.
	///
	/// A ray that misses does NOT end the stroke: dragging off the silhouette and back on is
	/// ordinary, and ending there makes the tool feel like it drops the gesture.
	/// </summary>
	public int MoveTo( Vec3 origin, Vec3 direction )
	{
		if ( !_stroking )
			throw new InvalidOperationException( "No stroke is running." );

		var hit = _bvh.Raycast( _mesh, origin, direction.Normal );

		if ( hit is null )
			return 0;

		var target = hit.Value.Point;
		var travelled = (target - _lastSample).Length;
		var spacing = MathF.Max( Radius * Spacing, 1e-6f );

		if ( travelled < spacing )
			return 0;

		var steps = Math.Min( (int)(travelled / spacing), MaxSamplesPerMove );
		var from = _lastSample;

		for ( var i = 1; i <= steps; i++ )
			Dab( from + (target - from) * ((float)i / steps) );

		_lastSample = target;
		return steps;
	}

	/// <summary>
	/// Finish the stroke, commit it as one edit, and write what it painted into the layer. Null when
	/// the stroke changed nothing — a click at zero strength is not worth an undo entry.
	/// </summary>
	public WeightEdit EndStroke()
	{
		if ( !_stroking )
			throw new InvalidOperationException( "No stroke is running." );

		var undo = _undo;

		_stroking = false;
		_undo = null;

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

		var before = new Dictionary<int, BoneWeight[]>( undo.Count );
		var after = new Dictionary<int, BoneWeight[]>( undo.Count );

		foreach ( var (vertex, previous) in undo.Previous )
		{
			before[vertex] = previous;
			after[vertex] = _weights[vertex];

			Layer.Capture( vertex, _weights[vertex], _skeleton );
		}

		var edit = new WeightEdit( before, after );

		_done.Push( edit );
		_undone.Clear();

		return edit;
	}

	public bool Undo() => Step( _done, _undone, forward: false );

	public bool Redo() => Step( _undone, _done, forward: true );

	bool Step( Stack<WeightEdit> from, Stack<WeightEdit> to, bool forward )
	{
		if ( _stroking || from.Count == 0 )
			return false;

		var edit = from.Pop();

		edit.Apply( _weights, forward );

		// THE LAYER GOES BACK TOO. Undoing the weights and leaving the paint recorded would look
		// right until the next rebuild put the undone stroke back on - a bug that only appears
		// several actions later and gets blamed on the rebuild.
		foreach ( var vertex in edit.Vertices )
			Layer.Capture( vertex, _weights[vertex], _skeleton );

		to.Push( edit );
		return true;
	}

	/// <summary>
	/// Take the paint off a set of vertices so the auto weights show through again — the "reset this
	/// bit" every paint tool needs, and the only way back to the binder's own answer short of
	/// clearing everything.
	///
	/// It is NOT an undo: the auto weights are recomputed by the caller, so this only forgets the
	/// paint. Committed as an edit so it is undoable like a stroke.
	/// </summary>
	public WeightEdit ClearPaint( IEnumerable<int> vertices, SkinWeights auto )
	{
		if ( _stroking )
			throw new InvalidOperationException( "A stroke is running." );

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

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

		var before = new Dictionary<int, BoneWeight[]>();
		var after = new Dictionary<int, BoneWeight[]>();

		foreach ( var vertex in vertices )
		{
			if ( vertex < 0 || vertex >= _weights.Count || !Layer.Clear( vertex ) )
				continue;

			before[vertex] = _weights[vertex];
			_weights[vertex] = auto[vertex];
			after[vertex] = auto[vertex];
		}

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

		var edit = new WeightEdit( before, after );

		_done.Push( edit );
		_undone.Clear();

		return edit;
	}

	/// <summary>
	/// How many vertices under the current brush cannot move because the painted bone is their only
	/// influence. Asked before painting, so the tool can say why a stroke is about to do nothing.
	/// </summary>
	public int LockedUnder( Vec3 point )
	{
		var stroke = NewStroke();
		stroke.Samples.Add( new WeightSample( point, Radius, Strength ) );

		return WeightBrush.CountLocked( _mesh, _weights, stroke, _bvh );
	}

	void Dab( Vec3 point )
	{
		var stroke = NewStroke();
		stroke.Samples.Add( new WeightSample( point, Radius, Strength ) );

		_undo.Absorb( WeightBrush.Apply( _mesh, _weights, stroke, mask: null, _bvh ) );
	}

	WeightStroke NewStroke() => new()
	{
		Kind = Brush,
		Falloff = Falloff,
		Bone = Bone,
		Target = Target,
		MirrorX = MirrorX,
	};
}