Editor/Effigy/HoleOperation.cs
using System;

namespace Effigy;

/// <summary>The shape of a hole. Named rather than a bare index, because these are also the
/// dropdown a user reads and reordering them is a live possibility.</summary>
public enum HoleStyle
{
	Simple,
	Counterbore,
	Countersink,
}

/// <summary>
/// The tool solid a hole cuts with.
///
/// A HOLE IS NOT A NEW CAPABILITY, IT IS A SHAPE. Holes already work as inner loops of a profile,
/// and cuts already work through <see cref="MeshBoolean"/>. What was missing was the convenience:
/// nobody wants to draw two concentric circles and extrude them to get a counterbore when the
/// numbers they actually have are "M6 clearance, 10mm head, 6 deep".
///
/// So this builds the NEGATIVE — the shape of the void — and the feature hands it to the boolean as
/// a tool. Everything about where it goes comes from the face that was picked.
/// </summary>
public static class HoleOperation
{
	/// <summary>
	/// Build the tool solid for one hole, drilled from <paramref name="at"/> along
	/// <paramref name="into"/>.
	///
	/// <paramref name="depth"/> of zero or less means "through everything", which is built as a
	/// cylinder long enough to leave the body either side — a through hole that stops exactly at the
	/// far surface is a coplanar-face boolean, and those are the ones that go wrong.
	/// </summary>
	public static PolyMesh Build( HoleStyle style, Vec3 at, Vec3 into, float diameter, float depth,
		float headDiameter, float headDepth, float sinkAngleDegrees, float through, int segments = 24 )
	{
		if ( diameter <= 0f )
			throw new InvalidOperationException( "A hole needs a diameter greater than zero." );

		if ( into.LengthSquared < 1e-12f )
			throw new InvalidOperationException( "A hole needs a direction to drill along." );

		segments = Math.Clamp( segments, 6, 256 );

		var direction = into.Normal;
		var blind = depth > 0f;

		// Through holes are built long, and even a blind one starts a whisker proud of the surface.
		// A tool whose end cap sits exactly ON the face it enters gives the boolean two coplanar
		// faces to resolve, which is the case that produces slivers rather than a clean mouth.
		var overshoot = MathF.Max( through, diameter ) * 0.05f + 1e-3f;
		var length = blind ? depth + overshoot : MathF.Max( through, diameter ) * 2f;

		// Built about +Z at the origin, then carried to the face. Primitives.Cylinder centres itself
		// on the origin, so the shaft is shifted to start at the mouth and run inward.
		var shaft = Primitives.Cylinder( diameter * 0.5f, length, segments );
		MeshTransform.Apply( shaft, Xform.Translate( new Vec3( 0, 0, length * 0.5f - overshoot ) ) );

		var tool = shaft;

		switch ( style )
		{
			case HoleStyle.Counterbore:
			{
				if ( headDiameter <= diameter )
					throw new InvalidOperationException(
						$"A counterbore's head ({headDiameter}) has to be wider than its shaft ({diameter})." );

				if ( headDepth <= 0f )
					throw new InvalidOperationException( "A counterbore needs a head depth greater than zero." );

				var head = Primitives.Cylinder( headDiameter * 0.5f, headDepth + overshoot, segments );
				MeshTransform.Apply( head, Xform.Translate( new Vec3( 0, 0, (headDepth + overshoot) * 0.5f - overshoot ) ) );

				tool = Combine( shaft, head );
				break;
			}

			case HoleStyle.Countersink:
			{
				if ( headDiameter <= diameter )
					throw new InvalidOperationException(
						$"A countersink's head ({headDiameter}) has to be wider than its shaft ({diameter})." );

				var angle = Math.Clamp( sinkAngleDegrees, 1f, 179f );

				// The cone's depth follows from its two diameters and the included angle, which is
				// how a countersink is actually specified — you are given 90 degrees and a head size,
				// never a depth.
				var half = angle * 0.5f * MathF.PI / 180f;
				var sinkDepth = (headDiameter - diameter) * 0.5f / MathF.Tan( half );

				tool = Combine( shaft, Cone( headDiameter * 0.5f, diameter * 0.5f, sinkDepth, overshoot, segments ) );

				break;
			}
		}

		// +Z onto the drilling direction, then into place.
		MeshTransform.Apply( tool, Align( direction ) );
		MeshTransform.Apply( tool, Xform.Translate( at ) );

		return tool;
	}

	/// <summary>
	/// A truncated cone, wide end at the mouth. Built by hand rather than revolved: it is two rings
	/// and a wall, and going through a sketch and a revolve to get it would drag a profile, an axis
	/// and a winding question into something with four numbers in it.
	///
	/// THE OVERSHOOT CONTINUES THE TAPER RATHER THAN SITTING ON TOP OF IT. A countersink's head
	/// diameter is specified AT THE SURFACE, and the tool has to start proud of that surface so the
	/// boolean is not resolving two coplanar faces. Lifting the wide ring straight up would put the
	/// head diameter above the material and leave the hole narrower than asked for at the only place
	/// anybody measures it. So the ring above the surface is the cone extrapolated along its own
	/// slope, which is wider still and entirely outside the part.
	/// </summary>
	static PolyMesh Cone( float mouthRadius, float bottomRadius, float depth, float overshoot, int segments )
	{
		var mesh = new PolyMesh();
		var top = -overshoot;
		var bottom = depth;

		// r = mouthRadius at z = 0, bottomRadius at z = depth. Walked back to z = -overshoot.
		var slope = depth > 1e-9f ? (mouthRadius - bottomRadius) / depth : 0f;
		var topRadius = mouthRadius + overshoot * slope;

		for ( var i = 0; i < segments; i++ )
		{
			var a = i / (float)segments * MathF.PI * 2f;
			mesh.AddVertex( new Vec3( MathF.Cos( a ) * topRadius, MathF.Sin( a ) * topRadius, top ) );
		}

		for ( var i = 0; i < segments; i++ )
		{
			var a = i / (float)segments * MathF.PI * 2f;
			mesh.AddVertex( new Vec3( MathF.Cos( a ) * bottomRadius, MathF.Sin( a ) * bottomRadius, bottom ) );
		}

		// Wall quads, wound so the outside faces out — the winding is what a boolean reads to know
		// which side is solid, and getting it backwards makes the tool a void full of material.
		for ( var i = 0; i < segments; i++ )
		{
			var next = (i + 1) % segments;

			mesh.AddFace( new[] { i, next, segments + next, segments + i } );
		}

		var cap = new int[segments];
		var floor = new int[segments];

		for ( var i = 0; i < segments; i++ )
		{
			cap[i] = segments - 1 - i;
			floor[i] = segments + i;
		}

		mesh.AddFace( cap );
		mesh.AddFace( floor );

		return mesh;
	}

	/// <summary>
	/// Two tool solids as one mesh.
	///
	/// NOT A UNION, and it does not need to be. This is the TOOL, and a boolean subtract takes away
	/// everything inside it; two overlapping shafts sharing an interface still enclose exactly the
	/// void that should go. Running a real union here would mean needing a boolean provider to build
	/// the argument to the boolean.
	/// </summary>
	static PolyMesh Combine( PolyMesh a, PolyMesh b )
	{
		var result = a.Clone();
		var offset = result.VertexCount;

		foreach ( var p in b.Positions )
			result.Positions.Add( p );

		foreach ( var face in b.Faces )
		{
			var indices = new int[face.Count];

			for ( var i = 0; i < face.Count; i++ )
				indices[i] = face.Indices[i] + offset;

			result.AddFace( indices, (Vec2[])face.UVs.Clone(), face.Material );
		}

		return result;
	}

	/// <summary>The rotation taking +Z onto <paramref name="direction"/>.</summary>
	static Xform Align( Vec3 direction )
	{
		var z = new Vec3( 0, 0, 1 );
		var dot = Math.Clamp( Vec3.Dot( z, direction ), -1f, 1f );

		if ( dot > 0.999999f )
			return Xform.Identity;

		// Exactly opposite: the cross product is zero and gives no axis, so any perpendicular will do.
		if ( dot < -0.999999f )
			return Xform.Rotate( new Vec3( 1, 0, 0 ), MathF.PI );

		return Xform.Rotate( Vec3.Cross( z, direction ).Normal, MathF.Acos( dot ) );
	}
}