Editor/Effigy/MeshBVH.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>
/// AABB tree over faces. Ray hits and radius queries against a dense sculpt mesh, where the
/// linear scan in <see cref="MeshRaycast"/> is no longer viable.
///
/// Built once; <see cref="Refit"/> updates bounds after a stroke sample. Sculpting never
/// changes topology, so the tree structure stays valid and only the boxes move. That is the
/// payoff for refusing dyntopo, and it is the reason this is a refittable BVH rather than a
/// rebuild-every-sample grid.
///
/// Faces are triangulated the same way <see cref="MeshRaycast"/> triangulates them, so a BVH
/// hit and a linear hit name the same face.
/// </summary>
public sealed class MeshBVH
{
const int LeafSize = 4;
const float Pad = 1e-5f;
struct Node
{
public Vec3 Min, Max;
public int Left; // child index; -1 if leaf
public int Right;
public int FaceStart;
public int FaceCount;
}
readonly Node[] _nodes;
readonly int[] _faces;
readonly int _faceCount;
readonly bool[] _seen;
MeshBVH( Node[] nodes, int[] faces, int faceCount, int vertexCount )
{
_nodes = nodes;
_faces = faces;
_faceCount = faceCount;
_seen = new bool[Math.Max( vertexCount, 1 )];
}
public int NodeCount => _nodes.Length;
public int FaceCount => _faceCount;
public bool IsEmpty => _nodes.Length == 0;
public static MeshBVH Build( PolyMesh mesh )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( mesh.FaceCount == 0 )
return new MeshBVH( Array.Empty<Node>(), Array.Empty<int>(), 0, mesh.VertexCount );
var faces = new int[mesh.FaceCount];
var centroids = new Vec3[mesh.FaceCount];
for ( var i = 0; i < mesh.FaceCount; i++ )
{
faces[i] = i;
centroids[i] = mesh.FaceCentroid( mesh.Faces[i] );
}
var nodes = new List<Node>( mesh.FaceCount * 2 );
BuildNode( mesh, faces, centroids, 0, mesh.FaceCount, nodes );
return new MeshBVH( nodes.ToArray(), faces, mesh.FaceCount, mesh.VertexCount );
}
static int BuildNode( PolyMesh mesh, int[] faces, Vec3[] centroids, int start, int count, List<Node> nodes )
{
var index = nodes.Count;
nodes.Add( default );
BoundsOfFaces( mesh, faces, start, count, out var min, out var max );
if ( count <= LeafSize || AllCentroidsEqual( centroids, faces, start, count ) )
{
nodes[index] = new Node
{
Min = min, Max = max,
Left = -1, Right = -1,
FaceStart = start, FaceCount = count
};
return index;
}
var axis = LongestAxis( min, max );
Array.Sort( faces, start, count, Comparer<int>.Create( ( a, b ) =>
{
var ca = Component( centroids[a], axis );
var cb = Component( centroids[b], axis );
var cmp = ca.CompareTo( cb );
return cmp != 0 ? cmp : a.CompareTo( b );
} ) );
var mid = count / 2;
if ( mid == 0 || mid == count )
{
nodes[index] = new Node
{
Min = min, Max = max,
Left = -1, Right = -1,
FaceStart = start, FaceCount = count
};
return index;
}
var left = BuildNode( mesh, faces, centroids, start, mid, nodes );
var right = BuildNode( mesh, faces, centroids, start + mid, count - mid, nodes );
nodes[index] = new Node
{
Min = min, Max = max,
Left = left, Right = right,
FaceStart = start, FaceCount = 0
};
return index;
}
/// <summary>
/// Recompute every box from the mesh's current positions. Face membership does not change.
/// Call after vertices move; do not call after topology changes — build a new tree.
/// </summary>
public void Refit( PolyMesh mesh )
{
if ( mesh is null )
throw new ArgumentNullException( nameof( mesh ) );
if ( mesh.FaceCount != _faceCount )
throw new ArgumentException(
$"Refit needs the same topology (built on {_faceCount} faces, mesh has {mesh.FaceCount})" );
if ( _nodes.Length == 0 )
return;
RefitNode( mesh, 0 );
}
void RefitNode( PolyMesh mesh, int index )
{
ref var node = ref _nodes[index];
if ( node.Left < 0 )
{
BoundsOfFaces( mesh, _faces, node.FaceStart, node.FaceCount, out node.Min, out node.Max );
return;
}
RefitNode( mesh, node.Left );
RefitNode( mesh, node.Right );
node.Min = CMin( _nodes[node.Left].Min, _nodes[node.Right].Min );
node.Max = CMax( _nodes[node.Left].Max, _nodes[node.Right].Max );
}
/// <summary>
/// Nearest face hit, same contract as <see cref="MeshRaycast.Raycast(PolyMesh, Vec3, Vec3)"/>.
/// </summary>
public MeshHit? Raycast( PolyMesh mesh, Vec3 origin, Vec3 direction )
{
if ( mesh is null || _nodes.Length == 0 )
return null;
if ( mesh.FaceCount != _faceCount )
throw new ArgumentException(
$"Raycast needs the same topology (built on {_faceCount} faces, mesh has {mesh.FaceCount})" );
var dir = direction.Normal;
if ( dir.LengthSquared < 0.5f )
return null;
var inv = new Vec3( SafeInv( dir.x ), SafeInv( dir.y ), SafeInv( dir.z ) );
MeshHit? best = null;
RaycastNode( mesh, 0, origin, dir, inv, ref best );
return best;
}
void RaycastNode( PolyMesh mesh, int index, Vec3 origin, Vec3 dir, Vec3 inv, ref MeshHit? best )
{
var node = _nodes[index];
var tMax = best is { } current ? current.Distance : float.MaxValue;
if ( !RayHitsBounds( origin, inv, 0f, tMax, node.Min, node.Max ) )
return;
if ( node.Left < 0 )
{
for ( var i = 0; i < node.FaceCount; i++ )
{
var fi = _faces[node.FaceStart + i];
if ( !MeshRaycast.HitFace( mesh, fi, origin, dir, out var t, out var point ) )
continue;
if ( best is { } held && t >= held.Distance )
continue;
best = new MeshHit( point, fi, mesh.FaceNormal( mesh.Faces[fi] ), t );
}
return;
}
RaycastNode( mesh, node.Left, origin, dir, inv, ref best );
RaycastNode( mesh, node.Right, origin, dir, inv, ref best );
}
/// <summary>
/// Vertices whose positions lie inside the sphere. The tree prunes faces whose boxes miss
/// the sphere; the returned set is then filtered by actual distance, so it matches a
/// brute-force scan of every vertex.
/// </summary>
public void VerticesInRadius( PolyMesh mesh, Vec3 point, float radius, List<int> results )
{
if ( results is null )
throw new ArgumentNullException( nameof( results ) );
results.Clear();
if ( mesh is null || _nodes.Length == 0 || radius < 0f )
return;
if ( mesh.FaceCount != _faceCount )
throw new ArgumentException(
$"Query needs the same topology (built on {_faceCount} faces, mesh has {mesh.FaceCount})" );
if ( _seen.Length < mesh.VertexCount )
throw new ArgumentException(
$"Query needs the same vertex count (built for {_seen.Length}, mesh has {mesh.VertexCount})" );
Array.Clear( _seen, 0, mesh.VertexCount );
var r2 = radius * radius;
Collect( mesh, 0, point, radius, r2, results );
}
void Collect( PolyMesh mesh, int index, Vec3 point, float radius, float r2, List<int> results )
{
var node = _nodes[index];
if ( !SphereHitsBounds( point, radius, node.Min, node.Max ) )
return;
if ( node.Left < 0 )
{
for ( var i = 0; i < node.FaceCount; i++ )
{
var face = mesh.Faces[_faces[node.FaceStart + i]];
foreach ( var vi in face.Indices )
{
if ( _seen[vi] )
continue;
_seen[vi] = true;
var d = mesh.Positions[vi] - point;
if ( d.LengthSquared <= r2 )
results.Add( vi );
}
}
return;
}
Collect( mesh, node.Left, point, radius, r2, results );
Collect( mesh, node.Right, point, radius, r2, results );
}
static void BoundsOfFaces( PolyMesh mesh, int[] faces, int start, int count, out Vec3 min, out Vec3 max )
{
min = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
max = new Vec3( float.MinValue, float.MinValue, float.MinValue );
for ( var i = 0; i < count; i++ )
{
var face = mesh.Faces[faces[start + i]];
foreach ( var vi in face.Indices )
{
var p = mesh.Positions[vi];
min = CMin( min, p );
max = CMax( max, p );
}
}
min = new Vec3( min.x - Pad, min.y - Pad, min.z - Pad );
max = new Vec3( max.x + Pad, max.y + Pad, max.z + Pad );
}
static bool AllCentroidsEqual( Vec3[] centroids, int[] faces, int start, int count )
{
var first = centroids[faces[start]];
for ( var i = 1; i < count; i++ )
{
if ( !centroids[faces[start + i]].AlmostEquals( first, 1e-8f ) )
return false;
}
return true;
}
static int LongestAxis( Vec3 min, Vec3 max )
{
var e = max - min;
if ( e.x >= e.y && e.x >= e.z )
return 0;
return e.y >= e.z ? 1 : 2;
}
static float Component( Vec3 v, int axis ) => axis == 0 ? v.x : axis == 1 ? v.y : v.z;
static Vec3 CMin( Vec3 a, Vec3 b ) =>
new( MathF.Min( a.x, b.x ), MathF.Min( a.y, b.y ), MathF.Min( a.z, b.z ) );
static Vec3 CMax( Vec3 a, Vec3 b ) =>
new( MathF.Max( a.x, b.x ), MathF.Max( a.y, b.y ), MathF.Max( a.z, b.z ) );
static float SafeInv( float d )
{
if ( d > 1e-12f || d < -1e-12f )
return 1f / d;
return d >= 0f ? 1e12f : -1e12f;
}
static bool RayHitsBounds( Vec3 origin, Vec3 inv, float tMin, float tMax, Vec3 bmin, Vec3 bmax )
{
var t0 = (bmin.x - origin.x) * inv.x;
var t1 = (bmax.x - origin.x) * inv.x;
if ( t0 > t1 )
(t0, t1) = (t1, t0);
tMin = MathF.Max( tMin, t0 );
tMax = MathF.Min( tMax, t1 );
if ( tMin > tMax )
return false;
t0 = (bmin.y - origin.y) * inv.y;
t1 = (bmax.y - origin.y) * inv.y;
if ( t0 > t1 )
(t0, t1) = (t1, t0);
tMin = MathF.Max( tMin, t0 );
tMax = MathF.Min( tMax, t1 );
if ( tMin > tMax )
return false;
t0 = (bmin.z - origin.z) * inv.z;
t1 = (bmax.z - origin.z) * inv.z;
if ( t0 > t1 )
(t0, t1) = (t1, t0);
tMin = MathF.Max( tMin, t0 );
tMax = MathF.Min( tMax, t1 );
return tMin <= tMax;
}
static bool SphereHitsBounds( Vec3 point, float radius, Vec3 bmin, Vec3 bmax )
{
var dx = point.x < bmin.x ? bmin.x - point.x : point.x > bmax.x ? point.x - bmax.x : 0f;
var dy = point.y < bmin.y ? bmin.y - point.y : point.y > bmax.y ? point.y - bmax.y : 0f;
var dz = point.z < bmin.z ? bmin.z - point.z : point.z > bmax.z ? point.z - bmax.z : 0f;
return dx * dx + dy * dy + dz * dz <= radius * radius;
}
}