Effigy.Tests/HoleFeatureTests.cs

Unit test file for hole features. It builds hole tool meshes (simple, counterbore, countersink) via HoleOperation.Build and validates their geometry, orientation, bounds and that the feature invokes the mesh boolean provider; it contains helper routines and a RecordingBoolean stub.

File Access
using System;
using Effigy;
using static Effigy.Tests.Report;

namespace Effigy.Tests;

/// <summary>
/// The hole tool, checked as a SHAPE rather than as a hole.
///
/// The cut itself is the engine's boolean, and it is not here. What is here is the negative this
/// hands it: the right diameter, pointing the right way, starting proud of the surface it enters,
/// and — for a counterbore or a countersink — wider at the mouth than at the bottom. Every one of
/// those can be wrong while the boolean succeeds, and a hole in the wrong place is a hole.
/// </summary>
public static class HoleFeatureTests
{
	public static void Run()
	{
		Section( "hole: the shape of the void" );
		TestASimpleHoleIsAShaftOfTheRightSize();
		TestItStartsProudOfTheSurfaceItEnters();
		TestItPointsWhereItWasAimed();
		TestABlindHoleStopsAndAThroughHoleDoesNot();
		TestACounterboreIsWiderAtTheMouth();
		TestACountersinkFollowsItsAngle();
		TestAHeadNarrowerThanTheShaftIsRefused();
		TestTheFeatureDrillsThroughTheProvider();
	}

	static (Vec3 Min, Vec3 Max) Bounds( PolyMesh mesh )
	{
		var min = new Vec3( float.MaxValue, float.MaxValue, float.MaxValue );
		var max = new Vec3( float.MinValue, float.MinValue, float.MinValue );

		foreach ( var p in mesh.Positions )
		{
			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 ) );
		}

		return (min, max);
	}

	/// <summary>Widest radius about the drilling axis among vertices near a given depth.</summary>
	static float RadiusAt( PolyMesh mesh, float z, float tolerance = 1e-3f )
	{
		var widest = 0f;

		foreach ( var p in mesh.Positions )
		{
			if ( MathF.Abs( p.z - z ) <= tolerance )
				widest = MathF.Max( widest, MathF.Sqrt( p.x * p.x + p.y * p.y ) );
		}

		return widest;
	}

	static void TestASimpleHoleIsAShaftOfTheRightSize()
	{
		// Drilled straight down from the origin, so the tool's own coordinates are the answer.
		var tool = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, -1 ),
			diameter: 0.5f, depth: 1f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 4f );

		var (min, max) = Bounds( tool );

		Check( "the shaft is the diameter it was given",
			MathF.Abs( (max.x - min.x) - 0.5f ) < 0.01f, $"{max.x - min.x:0.####} across" );
		Check( "and it is a closed solid the boolean can use", MeshValidator.Validate( tool ).IsClosed,
			MeshValidator.Validate( tool ).ToString() );
	}

	static void TestItStartsProudOfTheSurfaceItEnters()
	{
		// A tool whose end cap sits exactly ON the face it enters gives the boolean two coplanar
		// faces to resolve, and those are the ones that produce slivers rather than a clean mouth.
		var tool = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, -1 ),
			diameter: 0.5f, depth: 1f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 4f );

		var (min, max) = Bounds( tool );

		Check( "the mouth sits above the surface it drills into", max.z > 1e-4f, $"top at {max.z:0.#####}" );
		Check( "and the bottom is at the depth asked for",
			MathF.Abs( min.z + 1f ) < 0.05f, $"bottom at {min.z:0.####}, wanted -1" );
	}

	static void TestItPointsWhereItWasAimed()
	{
		// Drilling along the face's OUTWARD normal would put the tool entirely outside the body and
		// cut nothing — and a boolean that removes nothing succeeds, so it would look like the
		// feature quietly not working. The direction is the whole of that bug.
		var along = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 1, 0, 0 ),
			diameter: 0.4f, depth: 2f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 4f );

		var (min, max) = Bounds( along );

		Check( "aimed along +X, the tool runs along +X", max.x > 1.5f && min.x < 0.1f,
			$"x from {min.x:0.###} to {max.x:0.###}" );
		Check( "and is only its own diameter across the other axes",
			max.z - min.z < 0.5f && max.y - min.y < 0.5f,
			$"{max.y - min.y:0.###} by {max.z - min.z:0.###}" );

		// Straight up is the case the alignment maths has to special-case: the cross product with +Z
		// is zero and gives no axis to turn about.
		var up = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, 1 ),
			diameter: 0.4f, depth: 2f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 4f );

		var upBounds = Bounds( up );

		Check( "and drilling straight back up +Z still produces a solid",
			upBounds.Max.z > 1.5f && MeshValidator.Validate( up ).IsClosed,
			$"z to {upBounds.Max.z:0.###}" );
	}

	static void TestABlindHoleStopsAndAThroughHoleDoesNot()
	{
		var blind = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, -1 ),
			diameter: 0.3f, depth: 0.5f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 6f );

		var through = HoleOperation.Build( HoleStyle.Simple, Vec3.Zero, new Vec3( 0, 0, -1 ),
			diameter: 0.3f, depth: 0f, headDiameter: 0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 6f );

		var blindDepth = -Bounds( blind ).Min.z;
		var throughDepth = -Bounds( through ).Min.z;

		Check( "a blind hole stops where it was told", MathF.Abs( blindDepth - 0.5f ) < 0.05f,
			$"{blindDepth:0.###} deep" );
		Check( "a through hole runs past the body entirely", throughDepth > 6f,
			$"{throughDepth:0.###} deep against a body {6f} across" );
	}

	static void TestACounterboreIsWiderAtTheMouth()
	{
		var tool = HoleOperation.Build( HoleStyle.Counterbore, Vec3.Zero, new Vec3( 0, 0, -1 ),
			diameter: 0.4f, depth: 2f, headDiameter: 0.8f, headDepth: 0.5f, sinkAngleDegrees: 90f, through: 6f );

		var atHead = RadiusAt( tool, -0.5f, 0.02f );
		var atShaft = RadiusAt( tool, -1.5f, 0.6f );

		Check( "the head is the width it was given", MathF.Abs( atHead - 0.4f ) < 0.02f,
			$"radius {atHead:0.####}, wanted 0.4" );
		Check( "and the shaft below it is narrower", atShaft < 0.25f, $"radius {atShaft:0.####}" );
		Check( "the head stops at its own depth",
			RadiusAt( tool, -0.8f, 0.05f ) < 0.25f, "the head runs deeper than asked" );
	}

	static void TestACountersinkFollowsItsAngle()
	{
		// A countersink is specified by an included angle and a head size, never by a depth - the
		// depth follows, and working it out is the whole reason this is a feature rather than a
		// second circle in a sketch.
		var tool = HoleOperation.Build( HoleStyle.Countersink, Vec3.Zero, new Vec3( 0, 0, -1 ),
			diameter: 0.4f, depth: 2f, headDiameter: 1.0f, headDepth: 0f, sinkAngleDegrees: 90f, through: 6f );

		// 90 degrees included: the cone falls (1.0 - 0.4) / 2 = 0.3 over its own radius difference.
		var expected = (1.0f - 0.4f) * 0.5f / MathF.Tan( 45f * MathF.PI / 180f );
		var atCone = RadiusAt( tool, -expected, 0.02f );

		// MEASURED AT THE SURFACE, which is where a countersink's head diameter is specified and
		// where no vertex sits: the tool's rings are above the surface and at the bottom of the cone,
		// so the number that matters is the one between them. Reading the widest ring instead would
		// have called an over-wide mouth correct, which is exactly the bug this caught - the cone was
		// reaching its head diameter a third of a unit ABOVE the part.
		var (min, max) = Bounds( tool );
		var topRing = RadiusAt( tool, max.z, 0.02f );
		var atSurface = topRing + (atCone - topRing) * (max.z / (max.z + expected));

		Check( "the mouth is the head diameter where it meets the surface",
			MathF.Abs( atSurface - 0.5f ) < 0.03f,
			$"radius {atSurface:0.####} at the surface, wanted 0.5 (top ring {topRing:0.####} at z {max.z:0.###})" );
		Check( "and the cone reaches the shaft at the depth its angle implies",
			MathF.Abs( atCone - 0.2f ) < 0.05f, $"radius {atCone:0.####} at {expected:0.###} deep, wanted 0.2" );

		// A shallower included angle makes a deeper cone for the same head, which is the whole
		// relationship: get the tangent the wrong way up and every countersink comes out inverted.
		var steep = HoleOperation.Build( HoleStyle.Countersink, Vec3.Zero, new Vec3( 0, 0, -1 ),
			diameter: 0.4f, depth: 2f, headDiameter: 1.0f, headDepth: 0f, sinkAngleDegrees: 60f, through: 6f );

		var deeper = (1.0f - 0.4f) * 0.5f / MathF.Tan( 30f * MathF.PI / 180f );

		Check( "a narrower angle sinks deeper for the same head", deeper > expected,
			$"{deeper:0.###} against {expected:0.###}" );
		Check( "and the tool follows it", RadiusAt( steep, -deeper, 0.03f ) < 0.25f,
			"the 60 degree cone did not reach the shaft where its angle says" );
	}

	static void TestAHeadNarrowerThanTheShaftIsRefused()
	{
		// Not clamped: a counterbore whose head is narrower than its shaft is a number somebody typed
		// wrongly, and quietly widening it would drill a hole they did not ask for.
		var threw = false;

		try
		{
			HoleOperation.Build( HoleStyle.Counterbore, Vec3.Zero, new Vec3( 0, 0, -1 ),
				diameter: 0.8f, depth: 2f, headDiameter: 0.4f, headDepth: 0.5f, sinkAngleDegrees: 90f, through: 6f );
		}
		catch ( InvalidOperationException )
		{
			threw = true;
		}

		Check( "a head narrower than the shaft is refused", threw );
	}

	static void TestTheFeatureDrillsThroughTheProvider()
	{
		// End to end: the feature has to reach the boolean, with a tool that overlaps the body. A
		// tool aimed the wrong way still reaches the boolean and still succeeds, so the check is on
		// what it was HANDED, not on whether it was called.
		var previous = MeshBoolean.Provider;

		try
		{
			var stub = new RecordingBoolean();
			MeshBoolean.Provider = stub;

			var studio = new PartStudio();
			var box = studio.Add( new PrimitiveFeature() );
			box.SizeX.Value = 4f;
			box.SizeY.Value = 4f;
			box.SizeZ.Value = 4f;

			studio.Rebuild();

			var body = studio.Bodies[0];
			var top = -1;

			for ( var i = 0; i < body.Mesh.FaceCount; i++ )
			{
				if ( body.Mesh.FaceNormal( body.Mesh.Faces[i] ).Normal.z > 0.99f )
					top = i;
			}

			var hole = studio.Add( new HoleFeature() );
			hole.Diameter.Value = 0.5f;
			hole.Faces.Add( FacePlane.Capture( body, top, body.Mesh.FaceCentroid( body.Mesh.Faces[top] ) ) );

			studio.Rebuild();

			Check( "the feature built without complaint", hole.Error is null, hole.Error ?? "" );
			Check( "and reached the boolean", stub.Calls == 1, $"{stub.Calls} calls" );
			Check( "as a subtraction", stub.LastOp == BooleanOp.Subtract, $"{stub.LastOp}" );

			// The tool has to go DOWN into the block, not up off it.
			var (min, max) = Bounds( stub.LastTool );

			Check( "with a tool that goes into the material rather than off it",
				min.z < 1.9f, $"tool spans z {min.z:0.###} to {max.z:0.###}, block top is 2" );
			Check( "and overlaps the block it is cutting", max.z > 1.9f && min.z < 0f,
				$"tool spans z {min.z:0.###} to {max.z:0.###}" );
		}
		finally
		{
			MeshBoolean.Provider = previous;
		}
	}

	sealed class RecordingBoolean : IMeshBoolean
	{
		public int Calls;
		public BooleanOp LastOp;
		public PolyMesh LastTool;

		public bool TryApply( BooleanOp op, PolyMesh target, PolyMesh tool, out PolyMesh result, out string error )
		{
			Calls++;
			LastOp = op;
			LastTool = tool;

			result = target.Clone();
			error = null;
			return true;
		}
	}
}