Effigy/MaterialBrushSession.cs
using System.Collections.Generic;

namespace Effigy;

/// <summary>
/// The brush that lays an existing material onto faces, rather than colour onto vertices.
///
/// WHY IT IS NOT A MODE ON PaintSession, which it otherwise resembles closely. The two write
/// completely different things: a paint stroke is a stroke, replayed onto vertex colours every
/// rebuild and living on the PaintFeature; a material dab is an EDIT TO THE HISTORY, the same
/// FaceMaterialFeature assignment that dropping a material makes. Nothing about a material dab is
/// replayed, nothing about it is a colour, and the one thing it shares - "which faces did the
/// brush touch" - is a BVH query both can call. Sharing a session would have meant one class
/// holding two unrelated kinds of state and a mode flag deciding which half was live.
///
/// SO THIS HOLDS NO RESULT AT ALL. It answers where the cursor is and which faces a dab covers;
/// the caller does the assignment through <see cref="MaterialDrop.Brush"/> and rebuilds. That is
/// what keeps it testable without an editor, and what lets the dab be undone as an ordinary
/// history edit rather than as a special paint undo.
///
/// FACE GRANULARITY IS THE POINT, not a limitation to apologise for. A material belongs to a face
/// - see FaceMaterialEdit for why faces carry a slot number - so a dab covers whole faces or none
/// of them, and the brush ring is a way of picking several at once rather than a soft edge. On a
/// coarse box that means a click paints a whole side; subdivide first if you want the edge to
/// follow the brush, which is the same answer colour painting gives for its own resolution.
/// </summary>
public sealed class MaterialBrushSession
{
	readonly List<(string BodyId, PolyMesh Mesh, MeshBVH Bvh)> _targets = new();
	readonly List<int> _found = new();

	PolyMesh _mesh;
	MeshBVH _bvh;

	/// <summary>Brush radius in model units. The same meaning it has on the colour brush, so the
	/// two feel like one tool with two payloads.</summary>
	public float Radius = 0.1f;

	/// <summary>Which body the last hover hit, so a dab can be assigned to the right part when
	/// more than one body is in the studio.</summary>
	public string HoverBodyId { get; private set; }

	public MaterialBrushSession( PolyMesh mesh )
		: this( mesh is null ? null : new[] { ("", mesh) } )
	{
	}

	/// <summary>
	/// One session over every body, raycasting each and taking the nearest hit.
	///
	/// THE ONE-BODY LIMIT WAS THE SESSION, NOT THE TOOL. A dab is still per-body because
	/// MaterialDrop.Brush takes one Body; what this answers is "which body did the ray hit",
	/// which a single BVH over a merged mesh cannot, because face indices would not survive
	/// the merge.
	/// </summary>
	public MaterialBrushSession( IEnumerable<(string BodyId, PolyMesh Mesh)> bodies )
	{
		if ( bodies is null )
			throw new System.ArgumentNullException( nameof( bodies ) );

		foreach ( var (id, mesh) in bodies )
		{
			if ( mesh is null )
				continue;

			_targets.Add( (id ?? "", mesh, MeshBVH.Build( mesh )) );
		}

		if ( _targets.Count == 0 )
			throw new System.ArgumentException( "The material brush needs at least one body." );

		_mesh = _targets[0].Mesh;
		_bvh = _targets[0].Bvh;
	}

	/// <summary>The mesh the dabs land on, exposed so the editor can draw the cursor against the
	/// same surface the brush is querying.</summary>
	public PolyMesh Mesh => _mesh;

	/// <summary>A radius that is a sensible fraction of the part, so the brush is usable before
	/// anybody touches the number. The same twelfth of the bounds diagonal the colour brush
	/// starts at, deliberately - switching tools should not change the size of the ring.</summary>
	public float SuggestedRadius
	{
		get
		{
			var diagonal = _mesh.BoundsDiagonal;
			return diagonal > 1e-6f ? diagonal / 12f : 0.25f;
		}
	}

	/// <summary>Where the cursor sits on the surface, or null if the ray missed.</summary>
	public MeshHit? Hover( Vec3 origin, Vec3 direction )
	{
		var dir = direction.Normal;

		if ( dir.Length < 1e-6f )
			return null;

		MeshHit? best = null;
		var bestId = "";
		PolyMesh bestMesh = null;
		MeshBVH bestBvh = null;

		foreach ( var (id, mesh, bvh) in _targets )
		{
			var hit = bvh.Raycast( mesh, origin, dir );

			if ( hit is null )
				continue;

			if ( best is { } current && hit.Value.Distance >= current.Distance )
				continue;

			best = hit;
			bestId = id;
			bestMesh = mesh;
			bestBvh = bvh;
		}

		if ( best is not null )
		{
			HoverBodyId = bestId;
			_mesh = bestMesh;
			_bvh = bestBvh;
		}

		return best;
	}

	/// <summary>
	/// The faces one dab at <paramref name="hit"/> covers.
	///
	/// The far side of a thin wall is rejected by its normal, the same test PaintReplay's dab
	/// makes and for the same reason: the sphere reaches through the wall, and painting the back
	/// of what you are pointing at is never what was meant. A face exactly perpendicular belongs
	/// to its neighbour's dab rather than this one.
	///
	/// The returned list is REUSED between calls - a brush queries this every frame it moves, and
	/// a fresh allocation per frame is the kind of garbage a held drag makes thousands of. Copy it
	/// if you need to keep it.
	/// </summary>
	public IReadOnlyList<int> FacesAt( MeshHit hit )
	{
		_found.Clear();
		_bvh.FacesInRadius( _mesh, hit.Point, Radius, _found );

		var kept = 0;

		for ( var i = 0; i < _found.Count; i++ )
		{
			var faceIndex = _found[i];

			if ( faceIndex < 0 || faceIndex >= _mesh.Faces.Count )
				continue;

			if ( Vec3.Dot( _mesh.FaceNormal( _mesh.Faces[faceIndex] ), hit.Normal ) <= 0f )
				continue;

			_found[kept++] = faceIndex;
		}

		_found.RemoveRange( kept, _found.Count - kept );

		return _found;
	}
}