Effigy/MeshRaycast.cs
using System;
using System.Collections.Generic;
namespace Effigy;
/// <summary>Where a ray hit a mesh: the point, the face it hit, and that face's normal.</summary>
public readonly struct MeshHit
{
public readonly Vec3 Point;
public readonly int FaceIndex;
public readonly Vec3 Normal;
public readonly float Distance;
public MeshHit( Vec3 point, int faceIndex, Vec3 normal, float distance )
{
Point = point;
FaceIndex = faceIndex;
Normal = normal;
Distance = distance;
}
}
/// <summary>
/// A run of solid the ray passes through: where it went in, where it came out, and the point
/// halfway between them.
///
/// THE MIDPOINT IS WHERE A JOINT GOES. A bone belongs on the medial line of the limb it drives, and
/// a click can only ever name a surface — so a spine placed by clicking lands on the skin of the
/// chest, and every weight it drives is computed from a line running down the front of the model
/// rather than through the middle of it. Halfway between the front and the back of the material you
/// pointed at is that line, for the price of one more ray query.
/// </summary>
public readonly struct SolidSpan
{
/// <summary>Where the ray entered the material — the same point a click already gives you.</summary>
public readonly Vec3 Entry;
/// <summary>Where it left again.</summary>
public readonly Vec3 Exit;
public SolidSpan( Vec3 entry, Vec3 exit )
{
Entry = entry;
Exit = exit;
}
public Vec3 Midpoint => (Entry + Exit) * 0.5f;
/// <summary>How much material the ray crossed. Worth showing: it is the number that says
/// whether the midpoint is meaningfully different from the surface.</summary>
public float Thickness => (Exit - Entry).Length;
}
/// <summary>
/// Ray-mesh intersection, for clicking a face of a solid in the viewport.
///
/// PURE GEOMETRY, NO ENGINE SURFACE — which is why it lives here rather than in the editor. The
/// only thing the viewport contributes is the ray itself (Gizmo.CurrentRay, converted to Vec3);
/// everything about deciding which triangle it hit is ordinary math that can be built and proven
/// without s&box anywhere near it.
///
/// Faces are triangulated the same way EffigyPreview builds the render mesh — by Triangulate.Face
/// — so a click hits exactly the triangle that would actually be drawn there. A different
/// triangulation would occasionally pick a face whose diagonal put the real geometry on the other
/// side of the click, and a fan over a concave cap would let clicks land in the notch it wrongly
/// filled in.
/// </summary>
public static class MeshRaycast
{
/// <summary>
/// The nearest face of <paramref name="mesh"/> that <paramref name="origin"/> + t *
/// <paramref name="direction"/> hits, for t > 0. Null if nothing is hit.
/// </summary>
public static MeshHit? Raycast( PolyMesh mesh, Vec3 origin, Vec3 direction ) =>
Raycast( mesh, origin, direction, null );
/// <summary>
/// The same nearest-hit answer, with a <see cref="MeshBVH"/> doing the culling.
///
/// WHY THE OVERLOAD RATHER THAN A CACHE IN HERE: the linear scan below is O(faces), and the
/// viewport runs this every frame the face picker is armed. On a 60k-face import that is
/// ~13ms and ~25MB of garbage PER FRAME, which is the whole frame budget spent on deciding
/// what the cursor is over. A tree turns it into a handful of triangle tests. The kernel does
/// not own the cache because it does not know when the mesh stops changing - the caller does,
/// which is the same reason <see cref="Brush.Apply"/> takes its bvh rather than building one.
///
/// A bvh built on different topology is ignored rather than trusted, so a caller holding a
/// stale tree gets a slow answer instead of a wrong one.
/// </summary>
public static MeshHit? Raycast( PolyMesh mesh, Vec3 origin, Vec3 direction, MeshBVH bvh )
{
if ( mesh is null )
return null;
if ( bvh is not null && !bvh.IsEmpty && bvh.FaceCount == mesh.FaceCount )
return bvh.Raycast( mesh, origin, direction );
var dir = direction.Normal;
MeshHit? best = null;
for ( var fi = 0; fi < mesh.Faces.Count; fi++ )
{
if ( !HitFace( mesh, fi, origin, dir, out var t, out var point ) )
continue;
if ( best is { } current && t >= current.Distance )
continue;
best = new MeshHit( point, fi, mesh.FaceNormal( mesh.Faces[fi] ), t );
}
return best;
}
/// <summary>
/// Every face the ray crosses, nearest first — not just the one you can see.
///
/// The nearest hit is what a CLICK wants: you pointed at a surface and that surface is the
/// answer. Placing something INSIDE a solid is the opposite question — where does the material
/// begin and end along this line — and it cannot be answered by the front face alone.
///
/// Back faces count, and have to. The exit is by definition a face pointing away from the ray,
/// so a front-face-only scan finds every entry and no exit at all.
/// </summary>
public static List<MeshHit> AllHits( PolyMesh mesh, Vec3 origin, Vec3 direction )
{
var hits = new List<MeshHit>();
if ( mesh is null )
return hits;
var dir = direction.Normal;
for ( var fi = 0; fi < mesh.Faces.Count; fi++ )
{
if ( HitFace( mesh, fi, origin, dir, out var t, out var point ) )
hits.Add( new MeshHit( point, fi, mesh.FaceNormal( mesh.Faces[fi] ), t ) );
}
hits.Sort( ( a, b ) => a.Distance.CompareTo( b.Distance ) );
return hits;
}
/// <summary>
/// Nearest triangle of one face. Shared by the linear scan and the BVH so a click cannot
/// disagree with a stroke sample about which triangle was there.
/// </summary>
public static bool HitFace( PolyMesh mesh, int faceIndex, Vec3 origin, Vec3 dir, out float t, out Vec3 point )
{
t = 0f;
point = default;
if ( mesh is null || faceIndex < 0 || faceIndex >= mesh.Faces.Count )
return false;
var face = mesh.Faces[faceIndex];
if ( face.Count < 3 )
return false;
// A TRIANGLE IS ITS OWN TRIANGULATION, and the general path below allocates a corner list
// and a triangle list to rediscover that. Every mesh that arrives from outside the tool is
// triangles - an OBJ out of Blender, Meshy or a scan - so this is not a micro-optimisation
// on a rare case, it is the case, and it is what keeps a picking ray off the heap.
if ( face.Count == 3 )
{
if ( !TriangleHit( origin, dir,
mesh.Positions[face.Indices[0]],
mesh.Positions[face.Indices[1]],
mesh.Positions[face.Indices[2]], out var tri, out var triPoint ) )
return false;
t = tri;
point = triPoint;
return true;
}
var corners = new List<Vec3>( face.Count );
for ( var c = 0; c < face.Count; c++ )
corners.Add( mesh.Positions[face.Indices[c]] );
var hit = false;
var bestT = float.MaxValue;
var bestP = default( Vec3 );
foreach ( var (ia, ib, ic) in Triangulate.Face( corners ) )
{
if ( !TriangleHit( origin, dir, corners[ia], corners[ib], corners[ic], out var cand, out var p ) )
continue;
if ( cand >= bestT )
continue;
bestT = cand;
bestP = p;
hit = true;
}
if ( !hit )
return false;
t = bestT;
point = bestP;
return true;
}
/// <summary>
/// The edge of this face nearest <paramref name="point"/>, and how far the point sits from
/// that segment.
///
/// A click on a solid is a face hit first. Whether it was meant as an EDGE is a question of
/// how close the hit landed to a boundary — the viewport compares this distance to a
/// screen-pixel threshold, so a click in the middle of a face stays a face and a click near
/// a corner becomes the edge.
/// </summary>
public static bool ClosestEdge( PolyMesh mesh, int faceIndex, Vec3 point, out EdgeKey key,
out Vec3 closest, out float distance )
{
key = default;
closest = default;
distance = float.MaxValue;
if ( mesh is null || faceIndex < 0 || faceIndex >= mesh.Faces.Count )
return false;
var face = mesh.Faces[faceIndex];
if ( face.Count < 2 )
return false;
var found = false;
for ( var i = 0; i < face.Count; i++ )
{
var a = mesh.Positions[face.Indices[i]];
var b = mesh.Positions[face.Indices[(i + 1) % face.Count]];
var ab = b - a;
var lengthSq = ab.LengthSquared;
if ( lengthSq < 1e-20f )
continue;
var t = Vec3.Dot( point - a, ab ) / lengthSq;
if ( t < 0f )
t = 0f;
else if ( t > 1f )
t = 1f;
var on = a + ab * t;
var d = (on - point).Length;
if ( d >= distance )
continue;
distance = d;
closest = on;
key = new EdgeKey( face.Indices[i], face.Indices[(i + 1) % face.Count] );
found = true;
}
return found;
}
/// <summary>
/// Nearest hit across several bodies at once, with the winning body reported alongside it —
/// what a click in a multi-body studio actually needs.
/// </summary>
/// <summary>
/// The nearest face of any of <paramref name="bodies"/> that YOU CAN ACTUALLY SEE.
///
/// Effigy does not union bodies - two overlapping extrudes are two separate closed solids, and
/// the faces of one that fall inside the other are still there, still hit by a ray, and quite
/// invisible. Picking one is how you end up sketching on a plane buried inside your part,
/// which is exactly as confusing as it sounds: the highlight paints a rectangle straight
/// through the model and the sketch lands somewhere you never pointed at.
///
/// So a hit is discarded when the surface it landed on is inside another solid. Sorting the
/// candidates first means the common case - nothing overlapping - costs one containment test.
/// </summary>
public static (Body Body, MeshHit Hit)? Raycast( IEnumerable<Body> bodies, Vec3 origin, Vec3 direction ) =>
Raycast( bodies, origin, direction, null );
/// <summary>
/// The same pick, with the caller supplying a <see cref="MeshBVH"/> per mesh. See the note on
/// <see cref="Raycast(PolyMesh, Vec3, Vec3, MeshBVH)"/> for why the tree is passed in rather
/// than built here. Returning null from <paramref name="bvhFor"/> falls back to the scan, so a
/// cache that has not warmed up yet is slow rather than wrong.
/// </summary>
public static (Body Body, MeshHit Hit)? Raycast( IEnumerable<Body> bodies, Vec3 origin, Vec3 direction,
Func<PolyMesh, MeshBVH> bvhFor )
{
if ( bodies is null )
return null;
var list = new List<Body>();
foreach ( var body in bodies )
{
if ( body?.Mesh is not null )
list.Add( body );
}
var candidates = new List<(Body Body, MeshHit Hit)>( list.Count );
foreach ( var body in list )
{
if ( Raycast( body.Mesh, origin, direction, bvhFor?.Invoke( body.Mesh ) ) is { } hit )
candidates.Add( (body, hit) );
}
candidates.Sort( ( a, b ) => a.Hit.Distance.CompareTo( b.Hit.Distance ) );
var dir = direction.Normal;
foreach ( var candidate in candidates )
{
// Step back off the surface along the ray, so the test point is in the space the ray
// travelled through rather than exactly on the boundary, where inside/outside is a
// coin flip. Scaled by the distance travelled, since a sketch can be a unit across or
// a thousand.
var epsilon = 1e-4f * (1f + candidate.Hit.Distance);
var probe = candidate.Hit.Point - dir * epsilon;
var buried = false;
foreach ( var other in list )
{
if ( ReferenceEquals( other, candidate.Body ) )
continue;
if ( PointInsideSolid( other.Mesh, probe ) )
{
buried = true;
break;
}
}
if ( !buried )
return candidate;
}
return null;
}
/// <summary>
/// The first run of material the ray passes through, or null when it passes through none.
///
/// THE FIRST RUN, NOT THE WHOLE SPREAD. Entry to the LAST hit would be the middle of everything
/// the ray crosses, and on anything concave that is a point in mid-air — look between a model's
/// two legs and the "middle" is the gap. Entry to the first exit is always inside material,
/// which is the property worth having: a joint slightly off the medial line can be nudged, and a
/// joint floating in space beside the model cannot be told from one that is correct.
///
/// The consequence to know about is a SHELLED model, where the first run is the thickness of the
/// near wall rather than the hollow it encloses — so a bone lands inside that wall. Still inside
/// the part, still better than on its skin, and the preview draws the run it measured so it is
/// visible before the click rather than surprising afterwards.
///
/// Entry is the first face pointing back at the ray and exit the first one after it pointing
/// away, rather than simply the first two hits. A mesh with a stray inward-facing face — which a
/// boolean can leave behind — otherwise pairs an entry with an entry and reports a run that
/// starts and ends on the same side of the material.
///
/// ONE MESH, deliberately. Across several bodies a run would enter the near one and leave the
/// far one, putting its midpoint in the air between them — so the caller picks the body first
/// (Raycast over the bodies already answers that, buried surfaces and all) and measures inside
/// the one it names. Which is also the cheaper order: the caller doing the picking already has
/// the hit, and this way nothing casts the same ray twice.
/// </summary>
public static SolidSpan? FirstSolidSpan( PolyMesh mesh, Vec3 origin, Vec3 direction )
{
var dir = direction.Normal;
var hits = AllHits( mesh, origin, dir );
var entry = -1;
for ( var i = 0; i < hits.Count; i++ )
{
if ( Vec3.Dot( dir, hits[i].Normal ) < 0f )
{
entry = i;
break;
}
}
if ( entry < 0 )
return null;
for ( var i = entry + 1; i < hits.Count; i++ )
{
if ( Vec3.Dot( dir, hits[i].Normal ) > 0f )
return new SolidSpan( hits[entry].Point, hits[i].Point );
}
return null;
}
/// <summary>
/// Is a point inside a closed mesh? Crossing count along an arbitrary ray: odd is inside.
///
/// The direction is a fixed lopsided one rather than an axis, because an axis-aligned ray from
/// a point on a box lands exactly along edges and coplanar faces, and every such ray is a
/// coin-flip on whether a crossing gets counted once, twice or not at all.
/// </summary>
public static bool PointInsideSolid( PolyMesh mesh, Vec3 point )
{
if ( mesh is null || mesh.Faces.Count == 0 )
return false;
var direction = new Vec3( 0.5773f, 0.5771f, 0.5775f ).Normal;
var crossings = 0;
foreach ( var face in mesh.Faces )
{
if ( face.Count < 3 )
continue;
var corners = new List<Vec3>( face.Count );
for ( var c = 0; c < face.Count; c++ )
corners.Add( mesh.Positions[face.Indices[c]] );
foreach ( var (ia, ib, ic) in Triangulate.Face( corners ) )
{
if ( TriangleHit( point, direction, corners[ia], corners[ib], corners[ic], out _, out _ ) )
crossings++;
}
}
return (crossings & 1) == 1;
}
/// <summary>
/// Möller–Trumbore. Returns the ray parameter and world point on a hit with t > 0; a
/// back-facing triangle counts too, since a click through a thin wall should still register
/// something rather than nothing.
/// </summary>
public static bool TriangleHit( Vec3 origin, Vec3 dir, Vec3 a, Vec3 b, Vec3 c, out float t, out Vec3 point )
{
t = 0f;
point = default;
const float eps = 1e-7f;
var edge1 = b - a;
var edge2 = c - a;
var h = Vec3.Cross( dir, edge2 );
var det = Vec3.Dot( edge1, h );
if ( MathF.Abs( det ) < eps )
return false;
var invDet = 1f / det;
var s = origin - a;
var u = invDet * Vec3.Dot( s, h );
if ( u < -eps || u > 1f + eps )
return false;
var q = Vec3.Cross( s, edge1 );
var v = invDet * Vec3.Dot( dir, q );
if ( v < -eps || u + v > 1f + eps )
return false;
var candidate = invDet * Vec3.Dot( edge2, q );
if ( candidate <= eps )
return false;
t = candidate;
point = origin + dir * t;
return true;
}
}