Editor/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;
}
}