Editor/Effigy/ConvexHull.cs
using System;
using System.Collections.Generic;

namespace Effigy;

/// <summary>
/// The convex hull of a point cloud, by incremental insertion.
///
/// WHY THE KERNEL NEEDS ONE AT ALL: collision. A physics engine wants convex shapes, and a body that
/// has been through a boolean or a subdivide is not a primitive any more and cannot be described as
/// one. Its hull is the honest fallback — bigger than the part where the part is concave, never
/// smaller, and always convex.
///
/// INCREMENTAL RATHER THAN QUICKHULL, deliberately. Quickhull is faster on large clouds and this is
/// never given one: a CAD body is hundreds of vertices, not millions, and the incremental algorithm
/// is short enough to read in one sitting. Start with a tetrahedron, then for each remaining point,
/// delete every face it can see and stitch the resulting hole back to it.
///
/// It is exact for points in general position and degrades gracefully where it is not: a cloud that
/// is flat, collinear or a single point has no volume to enclose, and rather than emit a broken hull
/// this says so by returning null.
/// </summary>
public static class ConvexHull
{
	/// <summary>One face of the hull, as indices into the original point list.</summary>
	public readonly struct HullFace
	{
		public readonly int A, B, C;

		public HullFace( int a, int b, int c )
		{
			A = a;
			B = b;
			C = c;
		}
	}

	/// <summary>
	/// The hull of <paramref name="points"/>, or null when they enclose no volume.
	///
	/// Null rather than a degenerate hull: a caller with a flat cloud has a real decision to make
	/// (a box? a plane? refuse?) and handing it three coincident triangles makes that decision look
	/// like it has already been taken.
	/// </summary>
	public static (List<Vec3> Points, List<HullFace> Faces)? Build( IReadOnlyList<Vec3> points, float tolerance = 1e-6f )
	{
		if ( points is null )
			throw new ArgumentNullException( nameof( points ) );

		if ( points.Count < 4 )
			return null;

		if ( !StartingTetrahedron( points, tolerance, out var seed ) )
			return null;

		var faces = new List<HullFace>
		{
			new( seed[0], seed[1], seed[2] ),
			new( seed[0], seed[2], seed[3] ),
			new( seed[0], seed[3], seed[1] ),
			new( seed[1], seed[3], seed[2] ),
		};

		// Wound outward, so "can this point see this face" is one dot product with a consistent sign.
		var centre = (points[seed[0]] + points[seed[1]] + points[seed[2]] + points[seed[3]]) / 4f;

		for ( var i = 0; i < faces.Count; i++ )
		{
			if ( SignedDistance( points, faces[i], centre ) > 0f )
				faces[i] = new HullFace( faces[i].A, faces[i].C, faces[i].B );
		}

		var used = new HashSet<int>( seed );
		var scale = Extent( points );

		for ( var p = 0; p < points.Count; p++ )
		{
			if ( used.Contains( p ) )
				continue;

			var visible = new List<int>();

			for ( var f = 0; f < faces.Count; f++ )
			{
				if ( SignedDistance( points, faces[f], points[p] ) > tolerance * scale )
					visible.Add( f );
			}

			if ( visible.Count == 0 )
				continue;

			// The horizon: every edge of the visible region that the invisible region also owns. Those
			// are the edges the new point stitches to; an edge shared by two visible faces is interior
			// to the hole and goes with them.
			var horizon = new List<(int A, int B)>();
			var visibleSet = new HashSet<int>( visible );

			foreach ( var f in visible )
			{
				var face = faces[f];

				AddIfHorizon( faces, visibleSet, points, horizon, face.A, face.B );
				AddIfHorizon( faces, visibleSet, points, horizon, face.B, face.C );
				AddIfHorizon( faces, visibleSet, points, horizon, face.C, face.A );
			}

			if ( horizon.Count == 0 )
				continue;

			visible.Sort();

			for ( var v = visible.Count - 1; v >= 0; v-- )
				faces.RemoveAt( visible[v] );

			foreach ( var (a, b) in horizon )
				faces.Add( new HullFace( a, b, p ) );

			used.Add( p );
		}

		// Only the points that ended up on the hull. A caller storing this as a collision shape does
		// not want the interior ones, and a physics engine will discard them anyway.
		var keep = new List<int>();
		var remap = new Dictionary<int, int>();

		foreach ( var face in faces )
		{
			foreach ( var index in new[] { face.A, face.B, face.C } )
			{
				if ( remap.ContainsKey( index ) )
					continue;

				remap[index] = keep.Count;
				keep.Add( index );
			}
		}

		var hullPoints = new List<Vec3>( keep.Count );

		foreach ( var index in keep )
			hullPoints.Add( points[index] );

		var hullFaces = new List<HullFace>( faces.Count );

		foreach ( var face in faces )
			hullFaces.Add( new HullFace( remap[face.A], remap[face.B], remap[face.C] ) );

		return (hullPoints, hullFaces);
	}

	/// <summary>The hull as a mesh, for looking at and for measuring.</summary>
	public static PolyMesh ToMesh( IReadOnlyList<Vec3> points, float tolerance = 1e-6f )
	{
		if ( Build( points, tolerance ) is not { } hull )
			return null;

		var mesh = new PolyMesh();

		foreach ( var p in hull.Points )
			mesh.AddVertex( p );

		foreach ( var face in hull.Faces )
			mesh.AddFace( new[] { face.A, face.B, face.C } );

		return mesh;
	}

	static void AddIfHorizon( List<HullFace> faces, HashSet<int> visible, IReadOnlyList<Vec3> points,
		List<(int A, int B)> horizon, int a, int b )
	{
		for ( var f = 0; f < faces.Count; f++ )
		{
			if ( visible.Contains( f ) )
				continue;

			var face = faces[f];

			// The neighbour walks the shared edge the other way round, which is what identifies it.
			if ( (face.A == b && face.B == a) || (face.B == b && face.C == a) || (face.C == b && face.A == a) )
			{
				horizon.Add( (a, b) );
				return;
			}
		}
	}

	static float SignedDistance( IReadOnlyList<Vec3> points, HullFace face, Vec3 p )
	{
		var a = points[face.A];
		var normal = Vec3.Cross( points[face.B] - a, points[face.C] - a );

		return normal.LengthSquared < 1e-20f ? 0f : Vec3.Dot( normal.Normal, p - a );
	}

	static float Extent( IReadOnlyList<Vec3> points )
	{
		var min = points[0];
		var max = points[0];

		foreach ( var p in points )
		{
			min = new Vec3( MathF.Min( min.x, p.x ), MathF.Min( min.y, p.y ), MathF.Min( min.z, p.z ) );
			max = new Vec3( MathF.Max( max.x, p.x ), MathF.Max( max.y, p.y ), MathF.Max( max.z, p.z ) );
		}

		var size = (max - min).Length;
		return size > 1e-9f ? size : 1f;
	}

	/// <summary>
	/// Four points that actually enclose a volume: the two furthest apart, the one furthest from the
	/// line between them, then the one furthest from that plane.
	///
	/// Picked by extent rather than by taking the first four, because the first four points of a CAD
	/// mesh are routinely one face of it — coplanar, and no tetrahedron at all.
	/// </summary>
	static bool StartingTetrahedron( IReadOnlyList<Vec3> points, float tolerance, out int[] seed )
	{
		seed = null;

		var scale = Extent( points );
		int a = 0, b = 0;
		var best = -1f;

		for ( var i = 0; i < points.Count; i++ )
		{
			for ( var j = i + 1; j < points.Count; j++ )
			{
				var d = (points[i] - points[j]).LengthSquared;

				if ( d > best )
				{
					best = d;
					a = i;
					b = j;
				}
			}
		}

		if ( best <= tolerance * scale )
			return false;

		var axis = (points[b] - points[a]).Normal;
		var c = -1;
		best = -1f;

		for ( var i = 0; i < points.Count; i++ )
		{
			var offset = points[i] - points[a];
			var away = (offset - axis * Vec3.Dot( offset, axis )).LengthSquared;

			if ( away > best )
			{
				best = away;
				c = i;
			}
		}

		if ( c < 0 || best <= tolerance * scale )
			return false;

		var normal = Vec3.Cross( points[b] - points[a], points[c] - points[a] );

		if ( normal.LengthSquared < 1e-20f )
			return false;

		normal = normal.Normal;

		var apex = -1;
		best = -1f;

		for ( var i = 0; i < points.Count; i++ )
		{
			var height = MathF.Abs( Vec3.Dot( points[i] - points[a], normal ) );

			if ( height > best )
			{
				best = height;
				apex = i;
			}
		}

		if ( apex < 0 || best <= tolerance * scale )
			return false;

		seed = new[] { a, b, c, apex };
		return true;
	}
}